An event-dispatch helper returning `Promise<void>`. Caller did not await. The promise floated. Any rejection in the dispatcher became an unhandled rejection — crash on Node 16+, silent log on older runtimes.
The vulnerable diff
// events/dispatch.ts
export async function dispatch(event: string, payload: any): Promise<void> {
await emitToBus(event, payload);
}
// callers/create-user.ts
function createUser(req: Request, res: Response) {
const user = db.users.createSync(req.body);
// BUG: returns Promise<void>, caller doesn't await
events.dispatch("user.created", user);
res.json(user);
}What is wrong
Floating promises (calling an async function without `await` or explicit chaining) are the single most common async bug in TypeScript. The TS type system represents the missing await as a discarded `Promise<void>` — usable but easy to miss. ESLint's `no-floating-promises` rule catches most cases. Any rejection in the floated promise becomes an unhandled-promise-rejection event — on Node 15+ the default behavior is to crash the process. The fix is either to await (sequential execution) or to opt in to fire-and-forget with explicit error handling.
The attack
Production symptom:
# Logs:
[Unhandled Promise Rejection] EventBus error: connection lost
ERROR Process exit code 1
# Restart triggered. Five seconds later, same crash. Pattern repeats
# until the EventBus dependency is healthy again.Worse in older runtimes: silent log, the event is lost, no signal upstream.
Mesrai's review comment
mesraipilot · Bot · reviewed 1 min ago
[mesrai] [code-review] [Language] [Floating-Promise] [medium]
`events.dispatch(...)` returns a Promise the caller discards. Two
problems:
1. Errors in dispatch become unhandled rejections. Crash on Node 16+.
2. The dispatch may not complete before the response is sent —
ordering is non-deterministic.
Two fixes. Pick by whether the work blocks the response:
// Await (response sent after dispatch completes):
await events.dispatch("user.created", user);
res.json(user);
// Fire-and-forget with explicit error handling (response sent first):
events.dispatch("user.created", user)
.catch(err => log.error("dispatch failed", err));
res.json(user);
For genuinely fire-and-forget patterns, prefer a job queue (BullMQ,
SQS) instead of a floating promise — gives you retry + observability.The fix
// callers/create-user.ts — fixed
async function createUser(req: Request, res: Response) {
const user = await db.users.create({ data: req.body });
await events.dispatch("user.created", user);
res.json(user);
}Mark the caller async, await both calls. Errors propagate to the framework's error handler which can return a 500 cleanly. For truly fire-and-forget patterns, use a queue with retry, not a floating promise.
Why human review missed it
Floating promises are everywhere because the type system represents the discard as legal code. Even with `no-floating-promises` ESLint rule, the bug recurs as new code is added. Mesrai's rule pack catches every async function call whose return is dropped without explicit handling.
Related rules + further reading
Mesrai rule pack: language/no-floating-promises — flags discarded Promise returns.
TS-ESLint: no-floating-promises.
Top async bug class in 2024-2026 audits.
Takeaway
Await async calls. Or opt-in to fire-and-forget with explicit error handling. Mesrai catches every floating promise.