Mastodon
All articles
TypeScript

no-floating-promises vs no-misused-promises: When to Use Each

TypeScript types do not protect your async lifecycle. Learn the difference between floating promises and misused promises, and when each ESLint rule should catch them.

Gareth Whitbey·Senior Engineer
·16 min read
Share:

The project has strict TypeScript. CI is green. Type coverage is high. And yet production keeps losing telemetry events, swallowing API failures, or hitting race conditions that no type annotation predicted. The cause is usually the same: the type system checked that values matched their declared shapes, but nobody checked that Promises were actually handled.

Two @typescript-eslint rules exist to close this gap: no-floating-promises and no-misused-promises. They are not the same rule phrased differently. They catch different classes of async bugs, and most serious TypeScript projects need both.

TL;DR

  • no-floating-promisescatches Promise-valued statements that are created and then left unhandled — no await, no return, no .catch().
  • no-misused-promisescatches Promises passed into places that are not designed to handle them — conditionals, forEach callbacks, and void-return slots.
  • One rule asks: did you create a Promise and ignore it?
  • The other asks: did you put async behaviour into the wrong shape of code?
  • You usually want both.

If you want to audit your TypeScript codebase for broader reliability patterns beyond these two rules, CodeAva's code audit can surface code-quality issues, security smells, and structural risks in a single pass. Lint first, then audit the async boundaries your team keeps getting wrong.

What the TypeScript compiler does not catch

TypeScript checks types. It verifies that a function declared as () => Promise<User> returns a Promise that resolves to a User. It does not verify that the calling code actually does anything with that Promise.

This means code can be perfectly typed and still be operationally wrong. A save() call without await is type-safe. A Promise-valued expression inside an if condition type-checks. An async function passed to forEach compiles without error.

Promise lifecycle bugs live in sequencing, callback contracts, and ignored return values — areas that types alone do not constrain. ESLint rules with type information fill part of that gap. For a deeper look at what tsc misses and how a broader static analysis pipeline catches it, see Beyond TSC: Advanced Static Analysis for TypeScript.

Deep dive: no-floating-promises

This rule targets Promise-like statementsthat are created and then left unhandled. “Unhandled” means the Promise is not consumed by any of these patterns:

  • await— wait for the result and propagate rejection
  • return— delegate handling to the caller
  • .catch(...) or .then(..., onRejected)— handle the rejection locally
  • void— explicitly mark fire-and-forget intent (when ignoreVoid is enabled)

The rule also covers arrays of Promises that are created and then ignored. A common pattern:

// ❌ Flagged by no-floating-promises
// Each .map() call returns a Promise, but the array is ignored
[1, 2, 3].map(async (id) => {
  await api.process(id);
});

// ✅ Fixed: wrap in Promise.all so the batch is handled
await Promise.all(
  [1, 2, 3].map(async (id) => {
    await api.process(id);
  })
);

Example: calling save() without await

async function updateUser(user: User) {
  user.lastActive = new Date();

  // ❌ no-floating-promises: Promise returned by save() is ignored
  user.save();

  // ✅ Fix: await the result
  await user.save();
}

The call to save() returns a Promise. Without await, a database failure rejects that Promise with no handler. The function returns as if nothing happened. For a detailed look at how unhandled promises cause production failures, see Unhandled Promises in TypeScript: Why They Cause Real Production Bugs.

Deep dive: no-misused-promises

This rule catches Promises passed into positions where the surrounding code is not designed to handle them. The official rule categories are:

  • Conditionals— using a Promise in a boolean context like if (fetchData())
  • Spreads— spreading a Promise into an object or array literal
  • Void-return callbacks— passing an async function where the type signature expects () => void

Promise in a conditional

// ❌ no-misused-promises: a Promise is always truthy
if (fetchUser(id)) {
  console.log("User fetched");
}

// ✅ Fix: await the Promise
const user = await fetchUser(id);
if (user) {
  console.log("User fetched");
}

A Promise object is always truthy, regardless of what it resolves to. The condition will always pass, which is almost never what the developer intended.

Async callback in a void-return slot

// ❌ no-misused-promises (checksVoidReturn)
// addEventListener expects () => void, not () => Promise<void>
button.addEventListener("click", async () => {
  await api.track("click");
});

The DOM event listener contract expects a void-returning function. It receives a Promise but cannot observe its rejection. Whether this is acceptable depends on your error-handling strategy — which is why checksVoidReturn is a configurable option, not a universal ban.

The forEach(async ...) trap

This is one of the most recognisable async mistakes in TypeScript, and it triggers no-misused-promises because Array.prototype.forEach expects a callback that returns void:

// ❌ no-misused-promises: forEach ignores the Promise
items.forEach(async (item) => {
  await db.save(item);
});
// Code here runs BEFORE any save() completes

forEach calls the callback, receives a Promise, and discards it. The loop does not become sequential just because the callback is async. Errors are lost. All iterations run concurrently. Code after the loop executes before any callback finishes.

Fix patterns

Sequential execution — use for...of:

for (const item of items) {
  await db.save(item); // Each save completes before the next starts
}

Controlled concurrency — use Promise.all:

await Promise.all(
  items.map(async (item) => {
    await db.save(item); // All saves run concurrently, errors propagate
  })
);

The void operator: intentional, but not magical

void someAsyncCall() tells the linter and reviewers that the fire-and-forget is deliberate. When ignoreVoid is enabled (the default), no-floating-promises accepts it.

But void does not:

  • Catch errors thrown by the async call
  • Log the rejection
  • Attach a .catch() handler
  • Prevent an unhandledrejection event

It communicates intent. It does not provide safety. Teams should reserve voidfor code paths where fire-and-forget is truly acceptable — background analytics, non-critical cache warmup, or tasks where failure is handled by a global safety net (such as process.on('unhandledRejection')).

The most dangerous false confidence

The most dangerous async bug is the one that compiles cleanly. If a Promise is ignored or used in the wrong place, type safety alone will not save the runtime behaviour. voidmarks intent — it does not handle errors.

Rule options that actually matter

Both rules have configuration options. These are the ones that change real-world behaviour, not the ones you leave at defaults and forget.

no-floating-promises options

  • ignoreVoid (default: true) — allows void someCall() to suppress the lint error. Most teams keep this enabled. Disable it if you want zero tolerance for unobserved Promises.
  • checkThenables (default: true) — extends the rule to non-native thenables (objects with a .then() method). Keep it enabled unless you have a library that produces thenable-shaped objects that are not actual Promises.

no-misused-promises options

  • checksConditionals (default: true) — reports Promises used in if, ternary, while, and logical expressions. This is almost always a bug. Keep it enabled.
  • checksSpreads (default: true) — reports spreading a Promise into an object. Rare in practice, but a genuine mistake when it happens.
  • checksVoidReturn (default: true) — reports async functions passed into callback positions typed as returning void. This is the option that catches forEach(async ...) and async event handlers. It can be configured as a boolean or as an object with sub-options: arguments, attributes, properties, and variables. Tune it carefully instead of disabling it wholesale.

The decision matrix: which rule catches which bug?

The core distinction: no-floating-promises is about an ignored Promise result. no-misused-promises is about a Promise in the wrong place. The matrix below makes the contrast explicit.

Scenariono-floating-promisesno-misused-promisesWhy
save() without await✗ FlaggedIgnored Promise statement
[...].map(async ...) result unused✗ FlaggedArray of Promises is ignored
items.forEach(async ...)✗ FlaggedAsync fn in void-return slot
if (fetchData())✗ FlaggedPromise in boolean context (always truthy)
Async callback for void-typed handler✗ FlaggedContract mismatch: caller ignores Promise
void trackEvent()✓ SuppressedExplicit intent (with ignoreVoid)

Common async safety mistakes teams make

  1. Assuming TypeScript catches ignored Promises. It does not. tsc checks types, not async lifecycle.
  2. Using forEach(async ...) for database or network work. The loop fires all callbacks concurrently, errors are lost, and code after the loop runs before any callback completes.
  3. Putting Promise-returning expressions in conditionals. A Promise is always truthy. The branch always executes.
  4. Using void everywhere as a lint escape hatch. void marks intent; it does not handle rejections. Sprinkling void across the codebase hides real bugs.
  5. Disabling checksVoidReturn broadly. This disables one of the most valuable checks in no-misused-promises. If specific patterns are acceptable, use the sub-options to disable just those contexts.
  6. Ignoring arrays of Promises. [ids.map(async ...)] creates an array of Promises that nobody awaits. Wrap it in Promise.all, Promise.allSettled, Promise.any, or Promise.race.
  7. Treating “lint passed” as safe async architecture. Lint rules are a baseline. They catch local anti-patterns. They do not validate cross-function async flow, resource cleanup, or concurrency limits.

The enablement checklist

Follow this sequence when adopting both rules in an existing codebase:

  1. Enable both rules with type-aware linting. Both require @typescript-eslint/parser with parserOptions.project pointing to your tsconfig.json.
  2. Fix obvious floating Promise sites first. Search for save(), send(), fetch(), and similar calls that lack await.
  3. Replace forEach(async ...). Use for...of for sequential work or Promise.all(items.map(async ...)) for concurrent work.
  4. Review Promise-valued conditionals and callback contracts. Fix if (promise) patterns and async callbacks in void-return positions.
  5. Decide whether ignoreVoid stays enabled. Document the team policy: when is void acceptable, and what error handling must exist around it?
  6. Tune checksVoidReturn carefully. Use sub-options (arguments, attributes, properties, variables) to disable specific contexts rather than turning off the entire check.
  7. Reserve void for intentional fire-and-forget. Comment every void usage with a justification.
  8. Re-run linting in CI and track recurring patterns. Look for clusters of violations in specific modules — they often reveal architectural problems, not just one-off mistakes.

Side-by-side comparison

Dimensionno-floating-promisesno-misused-promises
Core questionDid you create a Promise and not handle it?Did you put a Promise in the wrong place?
TriggerPromise-valued statement with no consumerPromise in conditional, spread, or void-return slot
Key optionignoreVoidchecksVoidReturn
Catches forEach(async)NoYes
Catches if(promise)NoYes
Catches unawaited save()YesNo
Redundant?No — they cover different bug classes with minimal overlap

Tool integration: linting first, deeper audit second

no-floating-promises and no-misused-promises are your first line of defence. They catch local async anti-patterns at the file level. But async reliability is a codebase-wide concern: error handling conventions, retry policies, and cleanup patterns span multiple files and modules.

CodeAva's code auditcan surface broader code-quality signals — security smells, hardcoded secrets, and structural patterns that may indicate reliability risks. It complements ESLint rather than replacing it: lint catches the local Promise bugs, a code audit helps you see the bigger picture.

Conclusion and next steps

Async correctness in TypeScript is about lifecycle control, not just types. The compiler verifies shapes. It does not verify that every Promise is observed, awaited, or handled. That is the job of no-floating-promises and no-misused-promises.

They solve different problems: no-floating-promises catches Promises that are created and ignored. no-misused-promises catches Promises that are placed where they cannot be properly handled. The best teams enable both, tune them intentionally, and teach the difference.

Every Promise in your codebase should either be awaited, returned, handled with .catch(), or deliberately isolated with void and clear intent. Anything else is a production bug waiting for the right network failure.

Start with lint. Then audit your codebase for the patterns that lint alone cannot catch. For more on unhandled promises in production, read Unhandled Promises in TypeScript: Why They Cause Real Production Bugs. For how return await interacts with error handling inside try/catch, see return await in TypeScript: When It Actually Matters.

#typescript#eslint#no-floating-promises#no-misused-promises#async-await#promises#static-analysis#node-js#checksVoidReturn#ignoreVoid

Frequently asked questions

More from Gareth Whitbey

Found this useful?

Share:

Want to audit your own project?

These articles are written by the same engineers who built CodeAva\u2019s audit engine.