skip to content

A Node service occasionally leaves a request hanging forever with nothing logged, and the code path runs through a hand-written new Promise wrapper around a callback API. What bugs in such a wrapper leave a promise permanently pending, and how would you track one down?

level: seniorimportance: should knowfreq 45%

answer

  1. pending is a legal state, not an error
  2. no watchdog will complain for you
  3. some branch settles neither way
  4. the callback's throw escapes the executor
  5. async executor discards its own rejection

basics

~20 s

A promise that is never settled produces no error and no timeout, so the awaiting handler stalls silently. The usual causes are a branch that calls neither resolve nor reject, a throw inside the callback after the executor has returned, an async executor whose rejection is discarded, and an error channel that was never wired up.

solid answer

~50 s

A pending promise is a legitimate state, so nothing complains: whoever awaits it simply never resumes, and no timeout or warning fires. In a hand-written wrapper the causes are few and specific. A code path settles neither way — an early `return` in the callback, or a missing `else` around `resolve`. A throw inside the callback: the constructor's implicit try/catch covers only the executor's synchronous run, so a later throw escapes as an uncaught exception while the promise stays pending. An `async` executor whose awaited call rejects: that rejection is discarded and `resolve` is never reached. Or the underlying API reports failure on a channel you never subscribed to, such as an `error` event. To find it I check that every path settles exactly once, correlate the hang's timestamp with `uncaughtException` and `unhandledRejection` logs, and instrument the wrapper with a `settled` flag that warns when it stays false.

code

javascript · 15 lines
javascript
function loadRow(store, id) {
  return new Promise((resolve, reject) => {
    store.load(id, (err, row) => {
      if (err) return reject(err);
      if (!row) return;        // BUG: caller waits forever on "not found"
      resolve(JSON.parse(row)); // BUG: a parse throw escapes as uncaught
    });
  });
}

function loadRowFixed(store, id) {
  return new Promise((resolve, reject) => {
    store.load(id, (err, row) => (err ? reject(err) : resolve(row)));
  }).then((row) => (row ? JSON.parse(row) : null));
}

go deeper

for a junior

Remember that a promise stuck in the pending state raises no error at all, and that every branch inside a new Promise executor must end by calling resolve or reject exactly once.

for a middle

Explain the mechanics: the constructor catches only a synchronous throw, so an exception from the callback becomes an uncaught exception while the promise stays pending. Keep the executor limited to translating the callback.

for a senior

Walk a real diagnosis — correlate the stalled request with uncaughtException and unhandledRejection logs, instrument a settled flag with a captured stack, and separate a wrapper bug from a driver that genuinely never calls back.

for a principal

Make it a systemic control rather than a hunt: mandate first-party promise APIs over hand-written executors, require an end-to-end deadline so no request can stall indefinitely, and alert on requests with no terminal log line.

## Why a stuck promise is silent A promise has three states: pending, fulfilled, rejected. Pending is not an error condition — it simply means the answer has not arrived — so the runtime has nothing to report. There is no built-in deadline, no watchdog, no warning. An `await` on such a promise suspends its `async` function permanently: the rest of that function never runs, its `finally` blocks never execute, its HTTP response is never written, and everything the suspended frame captured stays reachable. The socket eventually times out on the client side and you see a gateway error with no matching server-side log. Node can even exit with status 0 while promises are still pending, because a pending promise is not a handle that keeps the event loop alive. ## Cause 1: a path that settles neither way The most common bug is an executor callback where some branch reaches the end without calling either function. ```js new Promise((resolve, reject) => { store.load(id, (err, row) => { if (err) return reject(err); if (!row) return; // <- hang: "not found" settles nothing resolve(row); }); }); ``` The author meant "nothing to do here", but the caller is waiting. Every guard clause inside an executor must end in `resolve` or `reject`. The mirrored version of this bug is a missing `else` — `if (err) reject(err); resolve(data);` — which does not hang, but hides the error, because the later `resolve` is silently ignored on an already-settled promise. ## Cause 2: a throw from the callback The `Promise` constructor wraps only the **synchronous** run of the executor: if the executor throws before returning, the promise rejects with that value. Once the callback fires from a later turn of the event loop, that protection is gone. ```js new Promise((resolve, reject) => { store.load(id, (err, raw) => { if (err) return reject(err); resolve(JSON.parse(raw)); // throws on bad JSON -> uncaught exception }); }); ``` A `JSON.parse` failure here propagates out of the callback into the host. In Node that becomes an `uncaughtException`, which by default prints the stack and exits with code 1 — but plenty of services install a handler that logs and continues, and then this promise is pending forever with only a stack trace elsewhere in the log to connect it. The fix is structural: keep the executor to pure translation and do the parsing in a `.then` or after the `await`, where a throw becomes an ordinary rejection. ## Cause 3: the async executor ```js new Promise(async (resolve) => { const row = await store.load(id); resolve(row); }); ``` The constructor ignores the executor's return value, so the promise the `async` executor produces is held by nobody. If `store.load` rejects, that rejection is unhandled and `resolve` is never reached: one line produces both an unhandled-rejection log and a permanently pending outer promise. The log points at the inner error, not at the stalled caller, which is why this one is so confusing in production. ## Cause 4: an unwired error channel Some APIs do not report failure through the callback at all. A stream or emitter signals it with an `error` event; a socket signals it by closing. A wrapper that listens only for the success event settles only on success — every failure becomes a hang. Whenever you promisify an event-based source, subscribe to the failure event in the same breath as the success one. ## Cause 5: the API never calls back Sometimes the wrapper is correct and the library is not: a driver with no internal timeout, holding a request whose connection dropped. The wrapper faithfully reflects that nothing happened. This is the case where a deadline must be imposed from outside, and it is worth distinguishing from the four bugs above before you go looking for one. ## Finding it 1. **Audit for settle coverage.** Read the executor as a decision tree and check that every leaf calls exactly one of `resolve`/`reject`. This is a two-minute review that finds most instances. 2. **Correlate the logs.** A hang caused by cause 2 or 3 leaves a footprint elsewhere: an `uncaughtException` or `unhandledRejection` entry at the same instant as the stalled request. Make sure both handlers log with a timestamp and a request id; running with `node --trace-uncaught` gives the throw site rather than just the propagation point. 3. **Instrument the wrapper.** In development, wrap `resolve`/`reject` in a closure that sets `settled = true`, and schedule an unrefed check that warns with a captured stack if the flag is still false after a few seconds. The stack tells you which call site stalled. 4. **Look at the heap.** A steady stream of hangs shows up as suspended `async` frames retaining request objects; a heap snapshot filtered on your request type will show them accumulating. 5. **Remove the wrapper.** If a first-party promise API exists — `node:fs/promises`, the driver's own promise surface — the whole class of bug disappears with the hand-written executor. ## The discipline that prevents all of it The executor should contain the call, the error branch, and the success branch, and nothing else. Every path settles exactly once; no `async`; no parsing; no logging that could throw. Anything richer belongs after the promise, where the language's own error propagation is doing the work for you.

  • Why does an uncaught exception from the callback leave the promise pending rather than rejecting it?
    The constructor's implicit try/catch surrounds only the executor's synchronous execution. By the time the callback runs, the constructor has long returned and there is no catch on that stack — the throw propagates to the host as an uncaught exception. Since neither `resolve` nor `reject` ran, the promise keeps its pending state permanently.
  • Does a promise that is never settled leak memory?
    The promise object itself is small and is collected once nothing references it. The cost is what it holds up: an `async` function suspended at that `await` retains its entire frame — request, buffers, database handles — for as long as the promise is reachable. A steady rate of hangs therefore looks like a slow leak in heap snapshots even though promises are not the bulk of it.
  • If Node exits cleanly while promises are pending, what does that tell you?
    That nothing else was keeping the event loop alive — a pending promise is not a handle, so it never holds the process open. A process that exits with status 0 in the middle of work is strong evidence that the awaited settlement depended on a callback nobody ever scheduled, rather than on work that was still in flight.

saying these in an interview costs you the question

  • Assumes an unsettled promise eventually errors on its own
  • Thinks the executor's try/catch covers the async callback
  • Uses an async function as the executor and awaits inside it
  • Adds a guard clause that returns without settling
  • Blames the event loop instead of auditing settle paths

context