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?
answer
- braced body needs an explicit return
- waiting is opt-in
- the promise has no subscriber
- undefined arrives immediately
- rejection escapes the chain
basics
~20 sThe 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 sThe 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 linesconst 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 ~100msgo deeper
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.
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.
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.
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