Floating Promises: The Async Bug That Ships Clean and Fails Silently
Floating promises — async calls nobody awaits — don't crash immediately. They fail silently, corrupt state, and get cut off by serverless runtimes. Here's what to look for in code review.
Floating Promises: The Async Bug That Ships Clean and Fails Silently
I reviewed a PR last month where an e-commerce checkout flow had been silently skipping the post-purchase analytics step for two weeks. No errors, no alerts, no unhappy users. The developer was senior, the code was reviewed, the tests passed. The bug was a missing await.
Floating promises — async calls that nobody awaits or catches — are one of the most common things I flag in PRs. They don't throw immediately. They don't fail your tests. They fail in production, quietly, and leave you with wrong data and no trail to follow.
The Mistake
The simplest form looks completely harmless:
// api/checkout/route.ts
export async function POST(request: Request) {
const order = await createOrder(request);
logAnalyticsEvent('purchase_complete', order); // no await
return Response.json({ orderId: order.id });
}logAnalyticsEvent is async. Without await, the response goes out immediately and the analytics call runs in the background — or it doesn't, because if it throws, nobody's listening. This is a floating promise: a promise that was created but never handled.
Everything looks fine. The route returns fast. The order gets created. Tests pass. You'd only notice in production monitoring, and even then only as a gradual drift in numbers.
Why This Breaks Things
Floating promises fail in three distinct ways:
Errors vanish. When you await a promise and it rejects, the error propagates to your nearest try/catch. When a floating promise rejects, the error lands in unhandledRejection — which in Node.js 15+ crashes the entire process, and in older versions silently disappears. No log. No Sentry event. The operation failed and you have no idea.
Execution order becomes wrong. You write code assuming operations happen in sequence. Without await, they don't. The next line of your function runs against stale state — whatever was true before the async operation started, not after it finished.
Serverless kills it mid-flight. Vercel Functions, AWS Lambda, Cloudflare Workers — they all suspend or terminate the execution context once the response is sent. Any floating promises get cut off mid-execution. You think the background work ran. It didn't.
Real-World Examples
Example 1: async in forEach — the most common footgun
This one shows up in almost every codebase:
// Before: looks like it waits, doesn't
async function notifyAllUsers(userIds: string[]) {
userIds.forEach(async (userId) => {
await sendEmail(userId, 'Important update');
});
console.log('All notifications sent'); // runs immediately, before any email sends
}forEach doesn't know or care that the callback returns a promise. It fires each callback, receives the returned promises, and ignores them. The loop completes synchronously. 'All notifications sent' prints before any email has been sent. If any sendEmail call throws, nobody catches it.
In production: your user notification job reports success, zero emails actually sent, zero errors logged.
// After: sequential, each waits for the previous
async function notifyAllUsersInOrder(userIds: string[]) {
for (const userId of userIds) {
await sendEmail(userId, 'Important update');
}
console.log('All notifications sent');
}
// After: parallel (faster), still safe
async function notifyAllUsersConcurrently(userIds: string[]) {
await Promise.all(
userIds.map((userId) => sendEmail(userId, 'Important update'))
);
console.log('All notifications sent');
}Use for...of when you need sequential processing or early exit on error. Use Promise.all when you want parallelism but still need to know when everything's done.
Example 2: The async upgrade that forgets the callers
A sync function gets refactored to be async. The call sites don't change. Nobody notices for weeks:
// Before refactor: synchronous
function saveUserPreferences_v1(userId: string, prefs: Preferences) {
localStorage.setItem(`prefs:${userId}`, JSON.stringify(prefs));
}
// After refactor: same name in the real diff, now hits a real API
async function saveUserPreferences(userId: string, prefs: Preferences): Promise<void> {
await api.put(`/users/${userId}/preferences`, prefs);
}
// Call site — unchanged from before the refactor
function handleFormSubmit() {
saveUserPreferences(user.id, formValues); // used to be fine, now floating
router.push('/dashboard');
}TypeScript won't warn about this with default settings. saveUserPreferences(...) is valid syntax even when the return type is Promise<void>. The function returns a pending promise, the call site discards it, the router navigates before preferences are saved. Users lose their settings changes. No error anywhere.
// After: await at every call site
async function handleFormSubmit() {
await saveUserPreferences(user.id, formValues);
router.push('/dashboard');
}When you change a function from sync to async, grep every call site before shipping.
Example 3: Background work in serverless handlers
This is the one that causes actual production incidents:
// Next.js App Router route handler
export async function POST(request: Request) {
const body = await request.json();
const order = await createOrder(body);
// "Background" work — not awaited
updateInventory(order.items); // dies when response sends
notifyWarehouse(order); // dies when response sends
return Response.json({ success: true, orderId: order.id });
}Vercel, Lambda, and Cloudflare Workers freeze execution when the response is returned. Your "background" operations get killed wherever they are mid-run. Inventory half-updated. Warehouse never notified. Your orders and inventory tables drift out of sync silently.
// After: await critical operations before responding
export async function POST(request: Request) {
const body = await request.json();
const order = await createOrder(body);
await Promise.all([
updateInventory(order.items),
notifyWarehouse(order),
]);
return Response.json({ success: true, orderId: order.id });
}If the work is genuinely optional (analytics, non-critical logging), use your platform's escape hatch. Vercel exposes after() from next/server, Cloudflare has ctx.waitUntil(). These register work that completes after the response is sent but before the process is killed:
import { after } from 'next/server';
export async function POST(request: Request) {
const order = await createOrder(await request.json());
after(async () => {
await logAnalytics('purchase_complete', order);
});
return Response.json({ orderId: order.id });
}Example 4: async in .filter() and .find()
This one compiles without complaint and does something completely different from what you expect:
// Before: async filter — always returns the full array
async function getActiveUserIds(userIds: string[]) {
return userIds.filter(async (id) => {
const user = await db.user.findUnique({ where: { id } });
return user?.isActive;
});
}filter returns original elements where the callback returned truthy. An async callback always returns a Promise. A Promise is always truthy. So filter(async fn) returns the entire array, every time, regardless of what the database says. getActiveUserIds returns all user IDs including inactive ones, no error thrown.
// After: fetch first, then filter synchronously
async function getActiveUserIds(userIds: string[]) {
const users = await Promise.all(
userIds.map((id) => db.user.findUnique({ where: { id } }))
);
return users
.filter((user) => user?.isActive)
.map((user) => user!.id);
}Same problem applies to .find(), .some(), and .every() — none of them await async callbacks.
The Fix
Turn on the ESLint rules. Two rules cover the main patterns:
{
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error"
}no-floating-promises flags promises that aren't awaited or .catch()-ed. no-misused-promises flags async callbacks passed to functions that don't handle the returned promise — like forEach, setTimeout, and DOM event handlers. Both are type-aware and need parserOptions.project set in your ESLint config.
Use void to signal intentional fire-and-forget. If you genuinely don't need to await something, be explicit:
void logAnalyticsEvent('purchase_complete', order);void tells the linter and your future readers: "I know this is async, I chose not to await it." Without it, there's no way to tell from reading whether the missing await is a bug or a decision.
Wrap async event handlers. Event listeners can't be async natively, so errors need explicit handling:
// Bad: unhandled rejection if the async handler throws
button.addEventListener('click', async () => {
await submitForm();
});
// Better: explicit rejection handling
button.addEventListener('click', () => {
submitForm().catch(console.error);
});How to Catch This in Code Review
These patterns are worth a second look every time:
- Async function calls on their own line, no
await. If a function is namedfetchUser,createRecord,sendEmail, orupdateStatus, it's almost certainly async. A bare call is a red flag. .forEach(asyncor.filter(async. These are almost always wrong..map(async fn)is also wrong if the returned array of promises isn't passed toPromise.all.- Async operations before an early return or a response send. Anything that kicks off work and then immediately returns without awaiting is a candidate for floating promises.
- Function signatures that changed from sync to async. If the diff shows a function gained
asyncor changed its return type toPromise<T>, every call site needs checking.
The two ESLint rules above will catch most of this automatically. Turn them on early.
Comments (0)
No comments yet. Be the first to share your thoughts!