Mesrai
Back to blog
// essayTechnical Deep Dive

Promise<void> Returning Inside a Sync Caller: A Quiet Bug

Real PR catch: async dispatch called without await — unhandled rejection crashes process. Await or explicit catch fix.

Mesrai TeamAugust 12, 20268 min read

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

tscallers/create-user.ts
// 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:

textexploit
# 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

textreview-comment.txt
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

tscallers/create-user.ts (fixed)
// 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.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.