skip to content

A shared `db.mjs` module in your service runs `const pool = await connectToDatabase()` at the top level. Startup has become slow and the service sometimes hangs on boot. Explain why, and how you would restructure it.

level: seniorimportance: should knowfreq 38%

answer

  1. the wait happens at import time
  2. cost is inherited by every consumer
  3. nowhere to hang a timeout or retry
  4. unreachable dependency means silent hang
  5. memoise the promise behind a getter

basics

~20 s

Import-time connection makes every module that imports db.mjs wait for the network, with no timeout, retry, or ordering control, and an unreachable database stalls boot forever. Move the connection into a lazily-invoked, memoised async function that callers await at the point of use.

solid answer

~50 s

Top-level `await` runs the connection during module evaluation, so the cost is paid by everything that imports `db.mjs` — directly or transitively — before any of their code runs. That has three consequences you feel in production: startup latency is inherited by every consumer, you have no place to attach a timeout, retry or backoff because module evaluation is not a call you control, and if the database is unreachable the awaited promise may never settle, so the process boots into nothing — not even a health endpoint, since that module is waiting too. The fix is to stop doing I/O at module scope: export a `getPool()` that memoises the connection promise on first call, so the wait happens at the first real query, inside code where `try`/`catch`, timeouts and retries all work normally. Keep top-level await for cheap, genuinely one-time setup near the entry point, not in shared leaf modules.

code

javascript · 15 lines
javascript
// db.mjs — lazy, memoised, retryable; no top-level await
async function connectToDatabase(url) {
  // stand-in for a real driver
  return { url, query: async (sql) => [sql] };
}

let poolPromise;

export function getPool() {
  poolPromise ??= connectToDatabase(process.env.DATABASE_URL).catch((err) => {
    poolPromise = undefined; // let a later call try again
    throw err;
  });
  return poolPromise;
}

go deeper

for a junior

Recognise that the connection runs when the module is imported, not when a query is made, and that everything importing it waits. Suggest moving the connection into a function that callers await.

for a middle

Explain the propagation — the async module defers all its dependents — and why module scope offers no place for a timeout, retry or catch. Show the memoised accessor and say why the promise, not the value, is cached.

for a senior

Diagnose the production shape: a container that starts and never becomes ready with empty logs, latency attributed to modules containing no await, and tests that need a live database. Then defend the restructure, including retry-on-failure and startup ordering.

for a principal

Set the boundary as policy — no network I/O during module evaluation, initialisation exposed as explicit async entry points with owned timeout and readiness semantics — and be ready to justify it against the convenience of import-time setup.

## What the code actually does ```js // db.mjs — the problem export const pool = await connectToDatabase(process.env.DATABASE_URL); ``` This is not "connect when first needed". It is *connect during module evaluation*, which happens as part of loading the graph, before your entry point's first statement runs. Every module that imports `db.mjs` becomes an async module too, and so does every module importing those — the asynchrony propagates up the whole dependency chain. ## Symptom one: inherited latency The connection cost is not paid once by `db.mjs`; it gates every consumer. A route module that imports `db.mjs` only to satisfy one rarely-used handler still cannot run its own top-level code until the connection is up. Worse, the cost is invisible where it is felt: the person debugging slow boot is reading `routes/users.mjs`, which contains no `await` at all. This also compounds. Add a second module doing `await loadFeatureFlags()` and a third doing `await warmCache()`, and if they are on the same dependency chain their waits are serialised rather than overlapped, because a module's dependencies are fully evaluated before its own body. ## Symptom two: no control surface Module evaluation is not a function call you make, so there is nowhere to put the things production code needs: - **Timeout.** You cannot race the evaluation against a deadline. - **Retry/backoff.** A failed evaluation is cached as failed; re-importing does not re-run the body. - **Ordering.** You cannot say "start the HTTP server first, connect lazily after". - **Tests.** Every test file that touches this module transitively opens a real connection at import time, so unit tests need a live database or fragile module mocking. ## Symptom three: the boot hang If `connectToDatabase()` returns a promise that never settles — a TCP connect to an address that blackholes packets, a driver waiting on an unbounded internal queue — then `db.mjs` never finishes evaluating. Its dependents never run. No error is thrown, no rejection is reported, and if your health check module is anywhere on that dependency chain (it usually is, in a monolithic entry point) the process is alive but answers nothing. Orchestrators see a container that started and never became ready, with an empty log. ## The restructure Move the I/O out of module scope and behind a memoised accessor: ```js // db.mjs — lazy and memoised let poolPromise; export function getPool() { poolPromise ??= connectToDatabase(process.env.DATABASE_URL); return poolPromise; } ``` Importing `db.mjs` is now free. The first caller triggers the connection, concurrent callers share the same in-flight promise (so you do not open N pools), and every later call gets the resolved one. Consumers do `const pool = await getPool()` inside an already-async request handler, where `try`/`catch` works, where you can wrap the call in a timeout or retry helper, and where a failure produces a 503 for one request instead of a dead process. If you want a failed connection to be retryable, clear the memo on rejection: ```js export function getPool() { poolPromise ??= connectToDatabase(process.env.DATABASE_URL).catch((err) => { poolPromise = undefined; // allow a later attempt throw err; }); return poolPromise; } ``` An equally defensible variant is an explicit `await initDb()` called once from the entry point, after the server is listening — that keeps startup order visible in one file instead of emerging from the import graph. ## When top-level await is still the right tool It is not a rule against the feature. Top-level await earns its place when the program genuinely cannot proceed without the value and the wait belongs to the program as a whole: picking an implementation with a conditional dynamic `import()`, instantiating a WebAssembly module, reading a config file at the entry point. The distinguishing questions are *how many modules inherit this wait*, and *can it fail or hang in production*. A cheap, local, always-succeeding await at the entry point is fine. A network round-trip in a shared leaf module is the case in the question. ## What a strong answer sounds like Name the mechanism (module evaluation is where the wait happens, and it is contagious upward), name the three concrete symptoms (inherited latency, no timeout/retry surface, silent hang), and give the memoised-accessor fix with the detail that concurrent callers must share one in-flight promise. Mentioning the testability cost usually lands well, because it is the symptom teams hit first.

  • Why memoise the promise rather than the resolved pool object?
    Because two callers can arrive before the first connection resolves. Caching the promise means the second caller joins the in-flight attempt; caching only the resolved value means both see an empty cache and start their own connection, so you open duplicate pools under concurrent startup — exactly when it hurts most.
  • Is there any version of this where you would keep the top-level await?
    Yes, at the composition root, for setup the whole program is defined by and that cannot hang — reading a local config file, instantiating a WebAssembly module, or choosing an implementation with a conditional dynamic import(). The test is how many modules inherit the wait and whether it can fail against a remote system.
  • How does import-time connection hurt your test suite specifically?
    Importing the module under test transitively evaluates db.mjs, so every test file opens a real connection before a single assertion runs. You end up requiring a live database for unit tests or resorting to loader-level module mocking. A lazy getPool() is trivially stubbed and never runs unless a test actually calls it.
  • Your service also imports a module doing `await loadFeatureFlags()`. Do the two waits overlap?
    Only if the two modules sit on independent branches of the graph. If one is a dependency of the other, the lower one is fully evaluated before the upper one's body starts, so the waits serialise. That dependence on graph shape is another reason to make startup order explicit rather than implicit in imports.

saying these in an interview costs you the question

  • Says top-level await connects lazily on first use
  • Thinks a timeout can be applied to module evaluation
  • Claims re-importing retries the failed connection
  • Caches the resolved pool instead of the in-flight promise
  • Says import-time I/O is fine because it happens once

context