In JavaScript, what is a "floating promise", and what happens if an async function you called without awaiting it ends up rejecting?
answer
- nobody is listening to the result
- try/catch never sees it
- async functions reject, they don't throw
- browser fires a global event
- Node exits non-zero by default
basics
~20 sA floating promise is one whose result nobody awaits or attaches a handler to. If it rejects, the error never reaches the caller's try/catch: browsers fire an unhandledrejection event and log it, and Node terminates the process by default.
solid answer
~50 sCalling an async function starts its work and hands you back a promise; it does not throw into your stack. If you ignore that promise, nothing is listening when it rejects — that is a floating promise. A surrounding `try/catch` sees nothing, because by the time the rejection happens the `try` block has already finished executing. The runtime then treats it as an unhandled rejection: in a browser a global `unhandledrejection` event fires and the console logs the reason; in Node the default policy since version 15 is to raise it as an uncaught exception and exit with a non-zero code. The fix is to make ownership explicit: `await` the call, `return` it so the caller owns it, or, for deliberate fire-and-forget, attach `.catch()` at the call site so the failure is logged rather than lost.
code
javascript · 20 linesasync function save() {
throw new Error('db down');
}
// Broken: async functions reject, they do not throw synchronously.
try {
save();
} catch (err) {
console.log('never runs:', err.message);
}
// Fixed: await connects the rejection to this try/catch.
async function main() {
try {
await save();
} catch (err) {
console.log('caught:', err.message);
}
}
main();go deeper
Know that calling an async function returns a promise instead of throwing, so try/catch around an un-awaited call catches nothing. Say plainly that you either await it, return it, or attach .catch().
Explain the mechanics: the runtime tracks whether a rejection handler was ever attached, and reports it as unhandled otherwise. Be able to say that .then(fn) and .finally() forward the rejection rather than consuming it.
Show that you care about the second failure mode too — the caller racing ahead of in-flight work — and describe how you keep floating promises out of a codebase with lint rules plus a global rejection hook wired to error reporting.
Own the policy angle: whether unhandled rejections should kill a service, what an intentional fire-and-forget contract looks like across a codebase, and how you keep the global hook a genuine bug detector instead of a catch-all that hides defects.
## What "floating" means An `async` function always returns a promise. Calling it does two things: it starts running the body synchronously up to the first `await`, and it gives you back a promise object representing the eventual outcome. Whether you keep that object is entirely up to you. A *floating* (or *dangling*) promise is one you discarded — no `await`, no `return`, no `.then()`/`.catch()` attached — so no code anywhere is subscribed to its outcome. While the promise fulfils, nothing bad is visible. The moment it rejects, the rejection has nowhere to go. ## Why try/catch does not help This is the single most common misunderstanding: ```js async function save() { throw new Error('db down'); } try { save(); // no await } catch (err) { console.log('never runs'); } console.log('reached'); ``` An `async` function never throws synchronously — even a `throw` on its first line is converted into a rejected promise. So `save()` returns normally, the `try` block completes, and the `catch` clause is out of scope forever after. Adding `await` reconnects them: `await` on a rejected promise throws the rejection reason at that point in the async function, which is exactly what a surrounding `try/catch` is positioned to see. The same trap appears without `async` at all — `fetchThing().then(use)` with no rejection handler floats the rejection just as thoroughly. ## What the runtime does about it Runtimes track, per promise, whether a rejection handler was ever attached. When a promise rejects and none was, the host is notified: - **In a browser**, the global object fires an `unhandledrejection` event, a `PromiseRejectionEvent` carrying `.promise` and `.reason`. The default action is to report the error to the console; calling `event.preventDefault()` suppresses that report. - **In Node**, the default mode since v15 is `throw`: the rejection is raised as an uncaught exception, printing the reason and terminating the process with a non-zero exit code — unless a `process.on('unhandledRejection', ...)` listener is registered, in which case that listener runs instead. Older Node versions only printed an `UnhandledPromiseRejectionWarning` and kept going, which is why a lot of legacy advice on this topic is now wrong. So "nothing happens" is never the right answer. Either your error is buried in a console log nobody reads, or your server process dies. ## Attaching some handler is not the same as handling rejection ```js fetchUser(id).then(render); // still floating ``` `.then(onFulfilled)` with no second argument does attach to the original promise, but it *forwards* the rejection to the promise it returns — and that derived promise has no handler. The unhandled rejection simply moves to the tail of the chain. The same goes for `.finally()`, which runs its callback and then re-rejects. Only a real rejection handler — `.catch(fn)`, `.then(ok, fail)`, or an `await` inside a `try` — actually consumes the failure. ## The discipline Every promise you create should have an owner. In practice that means one of three things at each call site: 1. **`await` it** — you handle the outcome here, inside a `try/catch` if the failure is recoverable. 2. **`return` it** — you delegate ownership to your caller, who will `await` it. 3. **Deliberately detach it, with a handler** — for genuine fire-and-forget work such as a metrics ping: ```js void recordMetric(event).catch(err => log.warn({ err }, 'metric failed')); ``` The `.catch()` is what turns fire-and-forget into something safe; the `void` operator is only a readability marker saying "the discard is intentional". A useful review heuristic: any expression statement that evaluates to a promise and does nothing with it is a bug until proven otherwise. That is also exactly the rule that async-aware linters encode, and it is why interviewers like this question — the missing `await` is one of the few bugs that both hides errors *and* changes control flow, since the caller races ahead while the work is still in flight.
- Does adding .then(render) to the call count as handling the rejection?No. `.then(onFulfilled)` with no rejection handler attaches to the original promise but forwards any rejection to the promise it returns, which now has no handler. The unhandled rejection just moves to the end of the chain. `.finally()` behaves the same way. Only `.catch(fn)`, `.then(ok, fail)`, or an `await` inside `try/catch` actually consumes a rejection.
- Besides losing the error, what else goes wrong when you forget the await?Control flow changes. The caller continues immediately while the work is still in flight, so ordering assumptions break: a response is sent before the write completes, a test finishes before its setup does, a resource is closed while the operation still needs it. The lost error is the loud symptom; the race is often the more damaging one.
- How would you catch these before they reach production?Make the discard syntactically visible and lint for it: an async-aware linter can flag any statement whose value is a promise that is never awaited, returned, or given a `.catch()`. Pair that with a global `unhandledrejection` / `process.on('unhandledRejection')` hook wired to your error reporter, so anything the linter misses surfaces as an alert rather than a console line.
It is like posting a letter with no return address: if it arrives, fine, but if it fails to deliver, the failure notice has nowhere to go.
saying these in an interview costs you the question
- Says a surrounding try/catch will catch it without await
- Thinks an ignored rejection is silently discarded with no effect
- Claims .then(fn) alone marks the rejection as handled
- Assumes Node just logs a warning and keeps running
- Believes async functions throw synchronously to the caller