Skip to content
Ultracite
Esc
↑↓navigate↵open⌘Jpreview
Research

What happens to a promise nobody awaits

Why a floating promise loses its errors, where the rules that catch it came from, what they can and can't see, how often real codebases float promises and why, and what agents do when the linter flags one.

Hayden Bleasel14 min read

A promise is a value that will settle later. When nothing awaits it, returns it or attaches a handler, nobody is listening when it does. If it fulfills, the result is dropped. If it rejects, the error has nowhere to go. Depending on where the code runs, that means a line in a console nobody reads, or a crashed server.

The lint rules for this are some of the few that catch real runtime bugs rather than style. no-floating-promises reports a promise left unhandled, and no-misused-promises reports a promise passed somewhere that won’t handle it, like an if condition or a forEach callback. Ultracite turns them on, along with require-await and no-await-in-loop. This post covers where the rules came from, what they can and can’t see, how often real codebases float promises and why, and what agents do when the linter flags one.

From callbacks to await

Asynchronous JavaScript started with callbacks. When Gallaba, Mesbah and Beschastnikh studied 138 programs in 2015, “every 10th function definition takes a callback argument”, and only 27% of the systems used promises at all. Callbacks pass errors by convention, as the first argument, and an error nobody checks simply disappears.

Promises were meant to fix that. The Promises/A+ specification reached 1.0 in December 2012, and Domenic Denicola argued at the time that promises give us back “functional composition and error bubbling in the async world.” They became part of the language in ES2015. async and await, which Luke Hoban first presented to TC39 in 2014 and Brian Terlson championed to stage 4, followed in ES2017. With await, a rejection becomes an exception thrown at the line that waits for it, and ordinary try/catch works again.

Error bubbling only works if something is waiting at the top. A promise nobody awaits has nowhere to bubble to. Runtimes had to decide what to do with those rejections. Browsers fire an unhandledrejection event, and if nothing cancels it, “the user agent may report” the error to the console. Node.js printed a deprecation warning from version 7 onward, and after a vote by its technical steering committee, Node.js 15 made an unhandled rejection crash the process by default in 2020. The release team said it brings behavior “of unhandled rejections in line with that for unhandled exceptions.” The same floating promise is a console message in a browser and a crash on a server.

The lint rule came from TypeScript. Josh Goldberg wrote no-floating-promises for TSLint in 2016, noting that “floating (unhandled) Promises can cause unexpected behavior.” typescript-eslint ported it in 2019, and added no-misused-promises a month later for the promises the first rule missed: ones “passed to code that will not handle it.” Both are errors in typescript-eslint’s recommended type-checked configuration.

What counts as handled

no-floating-promises reports a promise “created without any code set up to handle any errors it might throw”, in typescript-eslint’s words. Awaiting it, returning it, or attaching a rejection handler all count. So does putting void in front of it, which the rule treats as a deliberate marker for fire-and-forget. no-misused-promises reports a promise used as a condition, and an async function passed where the caller expects a function that returns nothing. require-await reports async functions that never await, and no-await-in-loop reports await inside a loop body.

The first two need type information. A call is only a floating promise if the type checker knows it returns a promise. Here’s what they make of the common cases, checked in Oxlint and typescript-eslint:

save(); // no-floating-promises
void save(); // passes: marked as intentional, still unhandled
save().catch(() => {}); // passes: handled, and the error is thrown away
const pending = save(); // passes: nothing checks it's ever awaited
if (save()) {} // no-misused-promises (and a TypeScript error)
items.forEach(async () => await save()); // no-misused-promises

The second and third lines are the ones to remember. void tells the rule a promise is floating on purpose, and the rule accepts it. It changes nothing at runtime. typescript-eslint’s docs were corrected in 2024 to say so plainly: “Voiding a Promise doesn’t handle it or change the runtime behavior.” An empty .catch handles the rejection by discarding it. Both make the rule pass, and neither makes the code any safer. (A general rule against empty functions will often flag the empty handler, which closes that exit partway.)

The fourth line is the rule’s biggest blind spot. A promise stored in a variable, a field or a map isn’t floating as far as the rule can tell, even if nothing ever awaits it.

Bugs in the error path

Promise bugs have been studied since soon after promises arrived. When Madsen, Lhoták and Tip built a formal model of JavaScript promises in 2017, they collected 21 promise bugs from StackOverflow. The most common type, 8 of 21, was a “missing return”: a promise created inside a callback and never passed back, so the chain lost it. A year later, their tool PromiseKeeper ran the test suites of 12 Node.js applications and found 1,864 promises with no rejection handler (920 after filtering out likely false alarms). It found something more telling about the tests themselves: five of the twelve suites never exercised a single rejected promise. Whatever happens when those promises reject, the tests weren’t checking.

DrAsync (ICSE 2022) counted async anti-patterns statically across 20 popular repositories and found 2,591. More than half were async functions with no await, the case require-await reports, and 293 were await inside a loop over an array. Refactoring those loops to Promise.all made individual hot spots up to 36% faster, but refactoring every instance in two projects produced “no meaningful change” in their test suites’ total run time.

Async mistakes also show up where they hurt in other ways. In a study of 452 commits that fixed flaky tests in large JavaScript projects, Hashemi, Tahir and Rasheed found that waiting incorrectly for async work caused 19.6% of them. And in a 2026 study of 633 TypeScript bug reports, asynchrony and event bugs were 3.6%, with patterns including “missing or misplaced await calls leading to premature execution.”

The most expensive version is a missing await in a condition. A promise is always truthy, so if (!isAllowed(user)) with an async isAllowed never fails. In 2026 alone that bug was behind authentication bypasses in Rocket.Chat, where users could log in “with any password” (CVSS 9.3), Unleash, StudioCMS and Pingvin Share X. no-misused-promises reports exactly this pattern, and TypeScript itself reports the simplest form of it. The Rocket.Chat bug was found by GitHub Security Lab’s AI agent.

So async bugs are a minority of all bugs, but a stubborn one, concentrated in error paths that tests don’t exercise, and occasionally severe. Platforms have started saying so. Cloudflare’s guide for Workers warns that floating promises “cause silent bugs: dropped results, swallowed errors, and unfinished work”, and recommends the lint rule to catch them.

Eight codebases, 260 floating promises

In October 2026 we measured these rules across the same eight codebases as our other research: Zod, Hono, tRPC, Vite, Astro, Excalidraw, Next.js (packages/next/src) and VS Code’s editor core. That’s 694,309 lines of source, excluding tests, generated and vendored code. We ran the type-aware rules with typescript-eslint and Oxlint, which agreed line for line.

no-floating-promises fired 260 times and no-misused-promises 127 times. Those are lower bounds: without installing each project’s dependencies, between 10% and 22% of the bare function calls in six of the repos had unresolved types, and the rule can’t judge a call whose type it doesn’t know.

The counts track whether a project enforces the rule. tRPC turns it on for its source and had none. Astro turns it off for its source “until we have the bandwidth to address all the errors” and had 37. Next.js had 132, with the rule off in its config next to a comment reading “TODO: enable in follow-up PR”. The pull request to enable it was closed without merging. Maintainers almost never suppress these rules one site at a time: two suppression comments in the whole corpus named an async rule. Projects either enforce them or don’t.

To find out whether the floating promises matter, we sampled 68 and classified each by reading the code and the function it calls:

  • About a third could lose something. 22 of 68 were latent bugs: a rejection nothing handles, or later code assuming work that might not have finished. 6 of those only duplicate an error that’s reported elsewhere, leaving 16 that lose an error or an ordering outright.
  • Most were deliberate. 43 were fire-and-forget by design, usually because the function catches its own errors. But only 3 of the 68 said so at the call site.

One of the latent bugs, from Excalidraw’s copy action, shows the most common shape, a synchronous try around an asynchronous call:

try {
  copyTextToSystemClipboard(getTextFromElements(selectedElements));
} catch (e) {
  throw new Error(t("errors.copyToSystemClipboardFailed"));
}

copyTextToSystemClipboard is async, so a failure rejects after the try has finished. The catch never runs, and the user never sees the error message it was written to show. The fix is to wait for the promise, or to move the error handling into a .catch.

We also went looking for real bugs. Searching the eight projects’ history for missing awaits, floating promises and unhandled rejections turned up 25 confirmed, fixed bugs. In tRPC, a discarded array of promises let one malformed WebSocket message crash the server, and the issue that followed noted the rule “would’ve prevented” it. tRPC turned it on afterwards. In VS Code, git.clone returned before the clone had finished. In Vite, a failing plugin hook in the file watcher could kill the dev server.

The rules would have flagged 13 of the 25. The other 12 are a fair map of their blind spots. In 7, the promise was stored or returned inside an object rather than dropped. In 4, a loose type hid it: an as any, or a callback typed to return unknown. In the last, the code awaited promises that had already started, one at a time, so a later one rejected unhandled while an earlier one was still being awaited. That one is the case no-await-in-loop reports.

That rule is the most common of the four, and usually wrong to obey blindly. Of 76 sampled awaits inside loops, two-thirds had to stay sequential: reading a stream, retrying, paginating, or stopping at the first match. Only 14 could have run in parallel. Just 12 of the 76 had a comment saying why they were sequential.

The error message offers a way out

A floating promise is an easy mistake for anyone writing glue code: call the API, move on, and forget that the call returns before the work is done. Agents write a lot of glue code. The question for a lint rule isn’t whether agents float promises, since everyone does. It’s what an agent does when the rule tells it to stop.

Here the rule hands it an answer. typescript-eslint’s error message ends with the options that satisfy it: a promise must be “awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the void operator.” Oxlint’s is shorter: “Promises must be awaited, add void operator to ignore.” For an agent working until the check passes, the cheapest change is printed in the error.

That change doesn’t fix anything. void is a marker the rule accepts, and its own documentation says it “doesn’t handle it or change the runtime behavior.” It isn’t a suppression comment either, so instructions that forbid an agent from disabling rules don’t cover it. An empty .catch(() => {}) is the other cheap exit, and it silences the error at runtime too. This is the same problem the cognitive complexity and type escape hatch posts describe: a check that can be satisfied without doing what it asks for, and an optimizer that will find the cheapest way through.

We didn’t find any study measuring how often agents take that exit, so we ran one.

Sixteen runs, three fixes

In October 2026 we took two real functions from the corpus, each with a floating promise that’s a genuine bug, and handed each one to Claude Code and to Codex four times, using the same prompt and permissions Ultracite uses when it passes lint errors to an agent:

  • Hono’s createPool runs tasks with a concurrency limit. When the pool is full, it retries a task with setTimeout(() => run(...)) and drops the promise. If that task throws, the caller’s promise never settles.
  • Astro’s compose combines several loggers into one. Its flush and close call each logger’s async method and drop the promise, so awaiting them doesn’t wait, and a failing logger’s error goes nowhere.

We checked every result against the original in more than 400 scenarios per function, on the success path and the failure path.

All 16 runs cleared the error. Three fixed the bug.

Twelve added void. One added a .catch that rethrew the error into a promise nobody held, which changes nothing. All 13 kept the success path exactly as it was, and left the failure path exactly as broken. Four runs, from both agents, produced this same change, byte for byte:

-      setTimeout(() => run(fn, promise, resolve))
+      setTimeout(() => {
+        void run(fn, promise, resolve)
+      })

The three fixes were all Claude Code, all on compose: await Promise.all(...) over the loggers, so that awaiting flush() actually waits for them and surfaces their errors. No run fixed createPool.

Both agents had reasons, and both reasons came from what they were told. Codex followed the error message. All four of its runs on compose said they added void because the diagnostics suggested it, and they did: besides the message itself, the rule’s first suggested fix reads “Add void operator to ignore.” Claude saw the bug. It described it in seven of its eight runs, and in three it said it had left the bug alone because fixing it would change runtime behavior: “I didn’t make that change because it alters runtime behaviour, which your rules ruled out.” The rules it meant were Ultracite’s: make the minimal change “while preserving runtime behavior.” For most lint errors that’s the right instruction. For this one, the bug is the runtime behavior.

Neither agent could check its work against the rule either. Claude had no permission to run the linter, and Codex ran the TypeScript compiler, which void passes. When the files were linted again afterwards, all 16 passed, so a tool that only re-runs the rule would have counted every run as a success.

That’s as much a finding about the instructions agents get as about the agents. When a linter hands an agent an error, the wording decides which fix is cheapest. Here the error message offered the silence, the instructions ruled out the fix, and the check afterwards couldn’t tell the difference.

Where the rules fall short

It needs types, and goes quiet without them. The type-aware rules can only see a promise the type checker can see. When dependencies are missing, or a value is typed any, a floating promise isn’t reported, and nothing tells you so. In our corpus the same code would have produced more errors with its dependencies installed.

It can’t see stored promises. A promise assigned to a variable, a field or a map is invisible to the rule, even if nothing ever awaits it. That was the cause of 7 of the 12 historical bugs the rules missed.

It can’t tell a decision from a mistake. Two-thirds of the floating promises we sampled were deliberate, and the rule flags them exactly like the third that weren’t. The answer depends on whether the called function handles its own errors, which the rule doesn’t look at.

The fixes it accepts aren’t always fixes. void and an empty .catch both satisfy it. void is the documented way to mark a deliberate fire-and-forget, but it handles nothing. An empty .catch handles the rejection by hiding it. A quarter of the .catch handlers in our corpus were empty or did nothing.

no-await-in-loop is mostly a prompt to explain. Two-thirds of the loops it flagged in our sample were correct as written. The rule is better read as “make sure this is sequential on purpose” than “this is slow”.

Make each one a decision

Turn on type-aware linting, and make sure types resolve. The rules that catch floating and misused promises only run with type information. Without it they report nothing, and with unresolved dependencies they report less than is there.

Decide what each floating promise means. If the caller needs the result or the error, await it or return it. If it’s genuinely fire-and-forget, give it a .catch that does something with the error: report it, log it, or retry. Use void only when the function handles its own errors, and add a comment saying so.

Never write an empty .catch. It satisfies the rule and loses the error. If an error truly doesn’t matter, say why in the handler.

Only pass async functions to APIs that wait for them. forEach, event listeners and timers ignore the promise you give them. Use for…of with await, Promise.all, or a wrapper that catches.

Comment the loops that must be sequential. Use Promise.all or Promise.allSettled when iterations are independent. Be careful starting promises early and awaiting them one at a time: if a later one rejects first, it rejects unhandled.

Review what an agent changes to fix one. Look for new voids, empty .catch handlers, and types widened to unknown or any. Each makes the error go away, and only some of them fix it.

A floating promise is a small mistake with a long reach: the error happens later, somewhere else, or nowhere at all. The rules that catch it are among the few lint rules that find real bugs, and in real code about a third of what they flag can lose something. But they accept a one-word answer that fixes nothing, and agents, like people in a hurry, take it. Turn the rules on, give them types to work with, and treat every new void as a question that needs an answer. The configuration docs show how to change these rules for your linter.

References

History and specification

Research

Incidents and guidance

Ship less slop

One command sets up your linter, formatter, editor and agents. Works with Oxlint, Biome and ESLint.