skip to content

Given `getUser().then(user => { saveUser(user); }).then(() => console.log('done'))`, where `saveUser` returns a promise, why does "done" print before the save finishes, and what is the fix?

level: middleimportance: must knowfreq 66%

answer

  1. braced body needs an explicit return
  2. waiting is opt-in
  3. the promise has no subscriber
  4. undefined arrives immediately
  5. rejection escapes the chain

basics

~20 s

The callback never returns the promise from saveUser, so it returns undefined and the chain does not wait for the save. Fix it by returning the promise: user => saveUser(user), or add an explicit return inside the braces.

solid answer

~50 s

The braced arrow body has no `return`, so the callback evaluates to `undefined`. A `.then` callback only makes the chain wait when it *returns* a thenable — otherwise the derived promise fulfils immediately with whatever was returned, and the next link logs "done" while `saveUser` is still in flight. Worse, the promise `saveUser` created is now floating: nothing in the chain is subscribed to it, so if it rejects, the chain's `.catch` will not see it and the runtime reports an unhandled rejection instead. The fix is to return it — `user => saveUser(user)`, or `user => { return saveUser(user); }` — which makes the next link adopt it and wait. This is the single most common promise bug, and the same trap exists inside `async` functions when you call an async helper without awaiting or returning it.

code

javascript · 10 lines
javascript
const saveUser = u =>
  new Promise(resolve => setTimeout(() => resolve('saved ' + u.id), 100));

Promise.resolve({ id: 1 })
  .then(user => { saveUser(user); })          // no return: nothing to wait for
  .then(result => console.log('A:', result)); // "A: undefined" immediately

Promise.resolve({ id: 2 })
  .then(user => saveUser(user))               // returned: adopted
  .then(result => console.log('B:', result)); // "B: saved 2" after ~100ms

go deeper

for a junior

Recognise the shape: a braced arrow body with no return hands undefined to the next link. Know that returning the promise is what makes the chain wait.

for a middle

Explain the mechanism in terms of adoption, and name the second symptom — the dropped promise is unsubscribed, so its rejection escapes the chain and surfaces as an unhandled rejection.

for a senior

Show how you would find this in a real service: an operation reporting success while the write failed, an unhandled-rejection log with no request context, and a review habit of asking who holds each promise.

for a principal

Own the prevention: a codebase convention that every function producing async work returns its promise, plus lint and unhandled-rejection alerting wired into CI and production so a floating promise cannot ship quietly.

## What the code actually does Split the chain into its two links. Link one is `.then(user => { saveUser(user); })`. When `getUser()`'s promise fulfils, this callback runs, calls `saveUser(user)` — which really does start the save and really does return a promise — and then discards it. A braced arrow body is a statement block: its value is `undefined` unless you write `return`. So the callback returns `undefined`. Link two therefore sees a fulfilled promise carrying `undefined` almost immediately, and logs `done`. Meanwhile the save is still running somewhere with nobody watching. ```js getUser() .then(user => { saveUser(user); }) // returns undefined .then(() => console.log('done')); // runs now, not after the save ``` ## The rule being violated A `.then` callback's return value is the entire interface to the next link. Return a plain value and the derived promise fulfils with it right away; return a promise or thenable and the derived promise **adopts** it, staying pending until it settles. Waiting is opt-in, and the opt-in is the word `return`. So the fix is to return the promise: ```js getUser() .then(user => saveUser(user)) // concise body: implicit return .then(() => console.log('done')); // now runs after the save ``` Equivalently, `user => { return saveUser(user); }`. If you also need the saved result later, return a promise that carries it — for example `user => saveUser(user).then(saved => ({ user, saved }))`. ## The second, nastier consequence Timing is the symptom people notice; the error handling is the bug that hurts in production. Because the chain never subscribed to the promise `saveUser` returned, that promise is **floating**. If the save rejects: - the chain's `.catch` never sees it — the chain already fulfilled with `undefined` and moved on; - no handler is ever attached, so the host reports an unhandled rejection (`unhandledrejection` in browsers, `unhandledRejection` in Node); - your request handler reports success while the write failed. That combination — a green log line and a silently lost write — is exactly why interviewers use this question. "Done" printing early is a scheduling curiosity; losing a rejection is a data-integrity incident. ## Why the mistake is so easy to make Three things conspire: **Arrow-body syntax.** `x => f(x)` and `x => { f(x); }` look nearly identical and behave completely differently. Adding a temporary `console.log` inside a concise-body arrow forces you to add braces, which silently deletes the return. **It looks like it works.** In the happy path with a fast local save, the process usually stays alive long enough for the write to land. The bug surfaces under load, in a serverless function that freezes after the response, or in a test that asserts on state right after `await`ing the chain. **`forEach` has the same shape.** `items.forEach(i => save(i))` starts every save and returns `undefined`; `forEach` ignores callback return values entirely, so nothing can await them. The fix there is a mapping into promises plus a combinator that waits for them. ## Diagnosing it When a chain "finishes too early", read every callback body and ask: does the last statement produce a promise, and does it leave the callback? Then check the value the next link receives — if it is `undefined` and you expected data, you have found it. Turning on an unhandled-rejection listener in development surfaces the floating promise directly, usually with a stack that points at the offending callback. ## The same rule in async functions The trap survives the syntax change. Inside an `async` function, `saveUser(user);` on its own line starts the work and moves on exactly like the braced arrow; `await saveUser(user);` or `return saveUser(user);` is what makes the caller wait. Anywhere a promise is produced, ask who is going to hold on to it — if the answer is nobody, you have a floating promise whether or not the code contains the word `then`.

  • Beyond the wrong ordering, what is the more dangerous consequence of dropping that return?
    The promise `saveUser` produced has no subscriber, so a rejection cannot reach the chain's `.catch`. The chain has already fulfilled with `undefined` and reported success, while the runtime separately reports an unhandled rejection. You get a green code path over a failed write — a silent data-loss bug rather than a visible error.
  • How does the same mistake appear in an async function?
    Writing `saveUser(user);` as a bare statement instead of `await saveUser(user);` or `return saveUser(user);`. The function moves on, returns, and its caller resolves while the save is still pending, and a later rejection is unhandled. The rule is identical: some caller must hold on to the promise.
  • Does `items.forEach(item => save(item))` wait for the saves?
    No. `forEach` ignores whatever the callback returns, so every `save` starts and the loop returns `undefined` immediately. To wait you must collect the promises — for example by mapping over the items — and hand the resulting array to a combinator that settles when they all do.

saying these in an interview costs you the question

  • Thinking the chain waits for any async work started inside a callback
  • Believing braces around an arrow body are purely cosmetic
  • Assuming the chain's .catch will still catch the dropped promise's rejection
  • Adding a setTimeout to "give the save time to finish"
  • Claiming the next .then receives the started promise object

context