Imagine a Node.js service that processes user registrations. It validates input, writes to a database, sends a welcome email, and logs the event. One day, the email stops sending. No errors, no crashes—just silent gaps in the user journey. The team at FunHive spent three days tracing the issue. The culprit? A single missing await in front of an async function. This is the 'forgotten await'—one of the most insidious async-await pitfalls because it rarely throws an exception. It simply… doesn't work. In this guide, we'll dissect how this bug happens, why it's so hard to detect, and what you can do to prevent it.
1. The Decision Frame: Who Must Choose and By When
Every JavaScript developer who writes async functions faces a choice: do I await this call or not? The decision seems trivial, but the consequences are not. A forgotten await means the Promise is created and scheduled, but the current function continues executing without waiting for the result. If that Promise rejects, the error becomes an unhandled rejection—or worse, it's silently swallowed if no rejection handler exists. The result: data inconsistencies, missing side effects, and hours of debugging.
This choice matters for anyone building production services: backend engineers using Express, Koa, or Fastify; frontend developers managing state with React or Vue; and teams working with serverless functions on AWS Lambda or Cloudflare Workers. The bug can appear in any codebase that mixes synchronous and asynchronous logic, especially in middleware chains, event handlers, and batch processing loops.
When must you decide? Ideally, at design time—before the code is merged. But in practice, the decision is often made implicitly, and the bug slips through code review. The cost of fixing it later is high: a silent data loss that may go unnoticed for weeks. Our composite scenario at FunHive involved a user registration flow where the welcome email was sent via an async function that returned a Promise. The developer wrote sendWelcomeEmail(user); instead of await sendWelcomeEmail(user);. The function executed, but the Promise was discarded. The email service never received the request because the async function's internal awaits were never resolved—the function returned immediately after the first synchronous line, leaving the rest of the work undone.
The decision to await is not just about correctness; it's about intent. Do you want fire-and-forget behavior (where you don't need the result) or do you need the operation to complete before proceeding? The problem is that JavaScript makes fire-and-forget too easy to write by accident. In this guide, we'll help you decide when to use await, when to use .catch(), and when to intentionally ignore a Promise.
2. The Option Landscape: Three Approaches to Handling Promises
When you call an async function, you have three main options: await the Promise, attach a .then() and .catch(), or ignore it entirely. Each has its place, but only one is safe for most scenarios.
Option 1: Await the Promise
This is the safest and most common approach. The current function pauses until the Promise settles. If the Promise rejects, the error is thrown at the point of the await, which you can catch with a try-catch block. This ensures that side effects (like database writes or email sends) complete before the next line runs. Use this when the result is needed for subsequent logic, or when you need to guarantee that the operation finishes before the function returns.
However, await can introduce latency if you await multiple independent Promises sequentially. In that case, you should use Promise.all or Promise.allSettled to run them concurrently while still awaiting all results.
Option 2: Use .then() and .catch()
This is the older Promise-based syntax, but it's still useful when you want to handle the result asynchronously without blocking the current function. For example, you might fire a logging call and attach a .catch() to handle errors, without awaiting it. This is a legitimate fire-and-forget pattern, but it requires explicit error handling. The risk is forgetting the .catch(), which leads to unhandled rejections in Node.js (which will crash the process in future versions).
Option 3: Ignore the Promise (Forgotten Await)
This is the accidental approach—the one that caused FunHive's data disaster. The developer writes someAsyncFunction(); without await and without .then(). The function executes synchronously up to its first internal await, then returns a Promise that is never used. If the function has no internal await (i.e., it's synchronous but declared async), it runs completely but still returns a Promise. In either case, any error thrown inside the async function becomes an unhandled rejection. Worse, if the function was supposed to modify data or trigger a side effect, that work may be partially executed or not executed at all.
There is one legitimate use case for ignoring a Promise: when you explicitly want fire-and-forget behavior and you've handled errors via a global unhandledRejection handler. But this is rare and risky. Most of the time, ignoring a Promise is a bug.
3. Comparison Criteria: How to Choose the Right Pattern
To decide which approach to use, evaluate these four criteria:
Do you need the result?
If the next line of code depends on the Promise's resolved value, you must await it. For example, if you need the user ID returned from a database insert, you cannot proceed without it. Similarly, if you need to know whether the operation succeeded before sending a response, you must await and handle errors.
Do you need the side effect to complete?
Even if you don't need the return value, you may need the side effect to finish. For example, sending a welcome email must complete before the user is marked as 'onboarded'. If you don't await, the email may still be in flight when the function returns, and the user could be redirected to a dashboard that assumes the email was sent. In such cases, await is necessary.
Can the operation fail?
If the operation can throw an error, you must handle that error. Using await inside a try-catch is the most straightforward way. If you choose not to await, you must attach a .catch() to handle the rejection. Ignoring the Promise entirely leaves the error unhandled, which in Node.js will eventually terminate the process (starting from Node 15, unhandled rejections cause process exit).
Is the operation independent and non-critical?
If the operation is truly fire-and-forget—like sending a non-essential analytics event—and you have a global error handler, you might choose to ignore the Promise. But even then, it's safer to use .catch() to log the error. The cost of a few extra characters is negligible compared to the cost of a silent failure.
In practice, most operations in a production service are critical enough to require await or at least .catch(). The forgotten await is almost always a bug, not a design choice.
4. Trade-offs Table: Detection Tools Compared
Since forgotten awaits are hard to spot visually, teams rely on tooling. Here's a comparison of common detection methods:
| Tool / Method | How it works | Pros | Cons |
|---|---|---|---|
| TypeScript strict mode | Flags async functions whose return value is ignored (when configured with noUnusedExpressions or no-floating-promises) | Catches most cases at compile time; zero runtime overhead | Requires TypeScript migration; may produce false positives for intentional fire-and-forget |
ESLint rule no-floating-promises | Warns when a Promise is created but not awaited or caught | Works with plain JavaScript; configurable to allow specific patterns | Requires linting setup; may be ignored in CI if not enforced |
| Code review checklist | Human reviewers look for async calls without await | No tooling cost; catches intent issues | Error-prone; slows down reviews; misses subtle cases |
| Runtime monitoring (unhandledRejection) | Logs or alerts when a Promise rejects without a handler | Catches errors that slip through; can be used in production | Only catches rejections, not silent successes; may crash process if not handled |
Each method has trade-offs. The best approach is to combine TypeScript or ESLint with a runtime unhandledRejection handler. At FunHive, we now use ESLint's no-floating-promises rule set to 'error' in CI, and we added a global process.on('unhandledRejection', ...) that logs the error and sends an alert. This combination catches forgotten awaits before deployment and provides a safety net if one slips through.
5. Implementation Path: How to Audit and Fix Your Codebase
If you suspect forgotten awaits are lurking in your code, follow these steps to find and fix them.
Step 1: Enable linting rules
Add ESLint with the no-floating-promises rule (from the @typescript-eslint plugin if using TypeScript, or the eslint-plugin-promise plugin for plain JS). Set it to 'error' in your CI pipeline. Run the linter across your entire codebase and review every warning. For each flagged line, decide whether to add await, attach .catch(), or explicitly suppress the warning with a comment explaining why the Promise is intentionally ignored.
Step 2: Add a global unhandledRejection handler
In Node.js, add process.on('unhandledRejection', (reason, promise) => { /* log and alert */ }). This will catch any Promise that rejects without a handler. In the browser, use window.addEventListener('unhandledrejection', ...). This handler should log the error with a stack trace and send an alert to your monitoring system. Do not silently swallow the error—log it.
Step 3: Conduct a manual audit of async functions
Search your codebase for patterns like asyncFunction() without await or .then(). Pay special attention to event handlers, middleware, and callbacks where async functions are often called but not awaited. For each case, verify the intent. If the function is meant to be fire-and-forget, add a .catch() that logs errors. If it's a bug, add await.
Step 4: Refactor critical paths to use Promise.allSettled
For operations where you need to run multiple async tasks concurrently and handle all results (including failures), use Promise.allSettled instead of Promise.all. This ensures that a single rejection doesn't cause the entire batch to fail. For example, when sending emails to a list of users, use Promise.allSettled to send all emails and then log which ones failed.
Step 5: Add integration tests that verify side effects
Write tests that check that side effects (like database writes or external API calls) actually happen. For example, mock the email service and assert that it was called with the correct arguments. This catches forgotten awaits because the mock will not be invoked if the Promise is not awaited.
6. Risks If You Choose Wrong or Skip Steps
Ignoring the forgotten await problem can lead to several types of failures, each with increasing severity.
Silent data loss
The most common risk is that a side effect never completes. In our FunHive scenario, the welcome email was never sent, but the user registration succeeded. The user never received the email, and the team didn't know until users complained. In a financial system, a missing await could mean a transaction is not recorded, leading to accounting discrepancies that are hard to trace.
Unhandled rejections crashing the process
Starting from Node.js 15, unhandled Promise rejections cause the process to exit. This means a forgotten await that leads to a rejected Promise will crash your server. In production, this can cause downtime and lost requests. Even if you have a process manager like PM2 that restarts the process, the crash can cause in-flight requests to fail.
Race conditions and inconsistent state
When you don't await a Promise, the order of operations becomes unpredictable. For example, if you write to a database and then send a response without awaiting the write, the response may be sent before the write completes. If the write fails, you've already told the client it succeeded. This creates inconsistent state between the client and server.
Difficult debugging
Forgotten awaits are hard to reproduce because they depend on timing. The bug may only appear under load, or when the async function takes longer than usual. Without a stack trace, developers often waste hours adding logs and trying to reproduce the issue. At FunHive, the team spent three days before discovering the missing await.
Technical debt and reduced confidence
A codebase with many forgotten awaits becomes unreliable. Developers lose trust in the system and may add redundant checks or workarounds, increasing complexity. Over time, the code becomes harder to maintain and more prone to new bugs.
7. Mini-FAQ: Common Questions About Forgotten Awaits
Q: Does calling an async function without await cause a memory leak?
A: Not directly. The Promise object is garbage collected once it settles, even if you don't await it. However, if the Promise holds a reference to a large object (like a buffer), that object may stay in memory until the Promise settles. In most cases, this is negligible. The real risk is unhandled rejections and incomplete side effects.
Q: Can I use void to explicitly ignore a Promise?
A: Yes, writing void someAsyncFunction(); tells readers (and linters) that you intentionally ignore the Promise. However, you should still attach a .catch() to handle errors. The void operator just suppresses the warning from some linters, but it doesn't handle rejections.
Q: What's the difference between fire-and-forget and a forgotten await?
A: Fire-and-forget is an intentional pattern where you don't need the result and you handle errors (usually with .catch()). A forgotten await is unintentional—the developer meant to await but forgot. The key difference is intent and error handling.
Q: Does TypeScript catch all forgotten awaits?
A: Not by default. You need to enable strict mode and use the noUnusedExpressions or no-floating-promises rules. Even then, TypeScript may not flag cases where the Promise is passed to another function or stored in a variable without being awaited.
Q: Should I always await inside try-catch?
A: Yes, unless you have a specific reason not to. Using try-catch around awaits ensures that errors are handled locally and don't become unhandled rejections. It also makes the control flow explicit.
Q: How do I audit a large codebase for forgotten awaits?
A: Start by running ESLint with the no-floating-promises rule. Then search for patterns like asyncFunction( without await in the same line. Use a script to find all calls to async functions and check if they are awaited. Finally, review the flagged cases manually.
Q: Can forgotten awaits cause data corruption?
A: Yes, if the async function modifies data and the modification is not completed before the next operation. For example, if you update a user's balance without awaiting, a subsequent read may see the old balance, leading to inconsistent state. In distributed systems, this can cause race conditions that corrupt data.
After reading this guide, you should be able to identify forgotten awaits in your code, choose the right pattern for each async call, and set up tooling to prevent future occurrences. Start by enabling ESLint's no-floating-promises rule and adding an unhandledRejection handler. Then, conduct a manual audit of your most critical paths. The time you invest now will save you from silent data disasters later.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!