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, noreturn, no.catch(). - no-misused-promisescatches Promises passed into places that are not designed to handle them — conditionals,
forEachcallbacks, 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 rejectionreturn— delegate handling to the caller.catch(...)or.then(..., onRejected)— handle the rejection locallyvoid— explicitly mark fire-and-forget intent (whenignoreVoidis 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() completesforEach 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
unhandledrejectionevent
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
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) — allowsvoid 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 inif, 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 returningvoid. This is the option that catchesforEach(async ...)and async event handlers. It can be configured as a boolean or as an object with sub-options:arguments,attributes,properties, andvariables. 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.
| Scenario | no-floating-promises | no-misused-promises | Why |
|---|---|---|---|
save() without await | ✗ Flagged | — | Ignored Promise statement |
[...].map(async ...) result unused | ✗ Flagged | — | Array of Promises is ignored |
items.forEach(async ...) | — | ✗ Flagged | Async fn in void-return slot |
if (fetchData()) | — | ✗ Flagged | Promise in boolean context (always truthy) |
Async callback for void-typed handler | — | ✗ Flagged | Contract mismatch: caller ignores Promise |
void trackEvent() | ✓ Suppressed | — | Explicit intent (with ignoreVoid) |
Common async safety mistakes teams make
- Assuming TypeScript catches ignored Promises. It does not.
tscchecks types, not async lifecycle. - 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. - Putting Promise-returning expressions in conditionals. A Promise is always truthy. The branch always executes.
- Using
voideverywhere as a lint escape hatch.voidmarks intent; it does not handle rejections. Sprinklingvoidacross the codebase hides real bugs. - Disabling
checksVoidReturnbroadly. This disables one of the most valuable checks inno-misused-promises. If specific patterns are acceptable, use the sub-options to disable just those contexts. - Ignoring arrays of Promises.
[ids.map(async ...)]creates an array of Promises that nobody awaits. Wrap it inPromise.all,Promise.allSettled,Promise.any, orPromise.race. - 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:
- Enable both rules with type-aware linting. Both require
@typescript-eslint/parserwithparserOptions.projectpointing to yourtsconfig.json. - Fix obvious floating Promise sites first. Search for
save(),send(),fetch(), and similar calls that lackawait. - Replace
forEach(async ...). Usefor...offor sequential work orPromise.all(items.map(async ...))for concurrent work. - Review Promise-valued conditionals and callback contracts. Fix
if (promise)patterns and async callbacks in void-return positions. - Decide whether
ignoreVoidstays enabled. Document the team policy: when isvoidacceptable, and what error handling must exist around it? - Tune
checksVoidReturncarefully. Use sub-options (arguments,attributes,properties,variables) to disable specific contexts rather than turning off the entire check. - Reserve
voidfor intentional fire-and-forget. Comment everyvoidusage with a justification. - 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
| Dimension | no-floating-promises | no-misused-promises |
|---|---|---|
| Core question | Did you create a Promise and not handle it? | Did you put a Promise in the wrong place? |
| Trigger | Promise-valued statement with no consumer | Promise in conditional, spread, or void-return slot |
| Key option | ignoreVoid | checksVoidReturn |
| Catches forEach(async) | No | Yes |
| Catches if(promise) | No | Yes |
| Catches unawaited save() | Yes | No |
| 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.





