The Missing `await`
That Cost $340K
The invoicing system worked perfectly for eleven months. Then an engineer added five lines of code, one missing keyword, and $340K in revenue disappeared into the void between a function that was called and a promise that was never awaited.
The invoicing system worked perfectly for eleven months. Then an engineer added a five-line Slack notification. On the first of the following month, 311 charges failed silently. No error was logged. No retry was triggered. No one was notified.
Accounts receivable found the gap six weeks later. $340K in revenue, gone.
The missing await is the most common reliability bug in modern backend code. It's also the most commonly missed in code review, because the code that causes it does exactly what it says it does — it calls the function. It just doesn't wait for the result.
This makes it invisible in three ways. First, the code reads correctly. The function is called, the arguments are right, the logic flows naturally. Second, it works in tests, because test environments are fast and failures are rare. Third, linters don't flag it, because calling an async function without await is syntactically valid. It's a feature of the language. It just happens to be a feature that loses your customers' money.
The crime scene
Here's the invoicing code before the change. Simplified, but structurally identical to what shipped:
async function processInvoices() { const accounts = await db.accounts.findActive(); for (const account of accounts) { const usage = await billing.calculateUsage(account.id); const invoice = await db.invoices.create({ accountId: account.id, amount: usage.total, status: "pending", }); await payments.charge(account.paymentMethod, usage.total); await db.invoices.update(invoice.id, { status: "sent" }); } }
Every step awaited. Every failure catchable. If payments.charge throws, the invoice stays "pending" and someone can investigate. The code is sequential, predictable, and correct.
Here's the PR that broke it. The diff is +5 −0:
async function processInvoices() { const accounts = await db.accounts.findActive(); for (const account of accounts) { const usage = await billing.calculateUsage(account.id); const invoice = await db.invoices.create({ accountId: account.id, amount: usage.total, status: "pending", }); await payments.charge(account.paymentMethod, usage.total); await db.invoices.update(invoice.id, { status: "sent" }); // NEW: notify sales for large invoices if (usage.total > 10_000) { notifySales(account, usage.total); // ← missing await } } }
Do you see it?
notifySales is an async function. It makes an HTTP call to the Slack API. But it's called without await. The function fires, returns a promise, and nobody catches it. The loop moves on immediately.
notifySales queued up database connections. Under load — 2,347 accounts in a tight loop — the connection pool exhausted. Subsequent payments.charge calls failed silently. 311 invoices marked as "sent" but never charged. $340K in uncharged revenue. Six weeks to notice.Now, the Slack notification alone wouldn't cause the billing failure. But notifySales had a side effect — it updated a last_notified_at timestamp on the account record. That database write also wasn't awaited internally. When Slack was slow (which it is, frequently), the unresolved promise queued up database connections. The payments.charge call failed with a connection timeout, but because the loop didn't have a try/catch around each account, the entire function crashed — after some invoices had been marked as "sent" and before others had been charged.
Five lines. One missing keyword. $340K in uncharged invoices. Six weeks to notice.
Why await bugs are invisible in review
The missing await is hard to catch because it exploits a specific weakness in how humans read code. We read for logic, not for execution model.
When you see notifySales(account, usage.total), your brain processes it as "notify sales about this account." The function is called. The arguments are passed. The intent is clear. Your mental model says "this happens" and moves to the next line.
But it doesn't happen. Not really. It starts happening, and then everything else moves on without it. The promise floats away like a balloon you forgot to tie down. Eventually it resolves or rejects, but by then, nobody is listening.
This is especially treacherous because the missing await doesn't cause a syntax error, a type error, or a lint warning. In TypeScript, calling an async function without await returns a Promise<void> — and discarding a return value is perfectly legal. ESLint has a rule for this (@typescript-eslint/no-floating-promises), but most projects don't enable it by default.
The four shapes of the missing await
Like most bugs worth writing about, this one doesn't always look the same. Here are the four shapes it takes in real codebases.
await. Returns success before anything completes.ifif block isn't..then() without await or .catch(). The response is sent before the chain resolves.for loop is a connection pool exhaustion event.Shape 1 — The obvious miss
The function returns { success: true } before either the save or the email completes. If saveOrder fails, the caller has already been told it succeeded. This is the easiest shape to spot — multiple function calls in sequence with no await, where the function names suggest I/O.
async function handleOrder(order: Order) { validateOrder(order); saveOrder(order); // ← async, not awaited sendConfirmation(order); // ← async, not awaited return { success: true }; }
Shape 2 — The conditional branch
The await is present on the critical path (charge and database write) but missing in the conditional branch. The reviewer's eye follows the main flow — charge, then save — and the side branch gets skimmed. This is the shape from the invoicing incident. Conditional branches are where await bugs hide most often.
async def process_payment(user_id: str, amount: float): charge = await stripe.charges.create(amount=amount, customer=user_id) if charge.amount > 5000: notify_compliance(user_id, charge.id) # ← fire-and-forget await db.payments.insert(charge_id=charge.id, status="completed")
Shape 3 — The .then() chain
The fetch starts, the .then() attaches a handler, but the outer promise is never awaited or caught. If the webhook URL is slow or down, this leaks a connection. The response has already been sent, so the caller thinks everything worked.
app.post("/api/orders", async (req, res) => { const order = await createOrder(req.body); // Send webhook — but don't wait for it fetch(order.webhookUrl, { method: "POST", body: JSON.stringify(order), }).then((r) => { if (!r.ok) logger.warn("Webhook failed"); }); res.json({ orderId: order.id }); });
Shape 4 — The loop
"Sync complete" logs before any sync has actually completed. If the external service rate-limits and starts rejecting calls, none of those rejections are caught. The loop shape is the most dangerous because it multiplies the impact. One unresolved promise is a minor issue. A thousand unresolved promises in a for loop is a connection pool exhaustion event waiting for a bad day.
async function syncAll(users: User[]) { for (const user of users) { syncToExternalService(user); // ← fire-and-forget in a loop } console.log("Sync complete"); }
What to look for in the diff
When you're reviewing a PR and scanning for missing await bugs, here's the checklist:
- Any function call that looks like I/O without
await. If the name containssave,send,notify,charge,create,update,delete,fetch,publish, orsync— and there's noawait— stop and check whether it's async. - Calls inside conditional branches. The happy path is usually awaited. Notifications, logging, analytics, webhooks inside
ifblocks are whereawaitgets dropped. - New function calls added to existing async functions. This is the exact shape of the invoicing bug. The existing code was correct. The new code was fire-and-forget. Check that the existing pattern continues.
-
.then()without a corresponding.catch()orawait. A.then()chain that isn't awaited or caught is a floating promise. The error has nowhere to go. -
Promise.allwith the wrong contents. Sometimes the array containsundefineds instead of promises because the mapped function doesn't always return one. ThePromise.allresolves instantly.
The ESLint rule you should enable today
If you're working in TypeScript, enable @typescript-eslint/no-floating-promises. It catches the most common shape — an async function called without await at the statement level. It won't catch every case (it misses promises inside .then() chains and some conditional patterns), but it catches the easy ones automatically, so your human reviewers can focus on the subtle ones.
In Python, there's no direct equivalent in the standard linter, but flake8-async and ruff have rules for unawaited coroutines. In Go, the compiler won't let you ignore a return value from a function that returns an error — which is one of the few cases where Go's verbosity actually prevents bugs.
The quiet catastrophe
The missing await isn't dramatic. There's no stack trace. There's no crash. There's no angry user on the phone. There's just a promise that resolves (or rejects) after everyone has stopped listening, and a system that looks healthy from the outside while slowly accumulating invisible damage.
The invoicing team didn't get a Sev1 alert. They didn't get paged. They got a spreadsheet six weeks later that didn't add up.
That's the nature of fire-and-forget bugs. They don't break things loudly. They break things silently, and the silence is what makes them expensive. By the time you find the gap, the damage has been compounding for weeks, and the five-line PR that caused it is buried under three hundred commits that nobody's going to bisect.
One keyword. Five characters. The most expensive typo in async programming.
Spot the fire-and-forget before it ships
The fire-and-forget pattern — calling an async function without await and without error handling — is one of the most frequently missed issues in practice sessions. It appears in the activity logging middleware PR, the payment retry PR, and several others. Build the instinct to see it before it costs your team six weeks of invisible damage.