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?
answer
- pending is a legal state, not an error
- no watchdog will complain for you
- some branch settles neither way
- the callback's throw escapes the executor
- async executor discards its own rejection
basics
~20 sA 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 sA 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 linesfunction 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
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.
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.
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.
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