JavaScript Promise and Async/Await: Complete Guide (2026)
Async JavaScript: The Foundation of Modern Web Development
JavaScript's single-threaded event loop model makes asynchronous programming fundamental to the language — network requests, file I/O, timers, and database queries all execute asynchronously to avoid blocking the UI. The evolution from callbacks to Promises to async/await has made asynchronous code increasingly readable, but each layer builds on the previous. Understanding Promises deeply is necessary to use async/await correctly and debug async issues effectively.
Promises: The Foundation
A Promise represents a value that may be available now, in the future, or never. It's in one of three states: pending (initial), fulfilled (completed successfully), or rejected (failed). Chain .then() handlers for success, .catch() for errors, and .finally() for cleanup that runs in both cases. Promise chaining — returning values or new Promises from .then() handlers — is the correct pattern for sequential async operations. Returning a Promise from .then() waits for that Promise to resolve before calling the next .then(), enabling clean sequential async flows without callback nesting.
async/await: Synchronous-Style Async Code
async functions always return a Promise. await pauses execution of the async function until the awaited Promise resolves, then returns its value. The try/catch block handles both synchronous errors and rejected Promises in async functions, replacing the .catch() pattern with familiar error handling syntax. The critical mistake: using await in a loop sequentially when the operations are independent — this makes operations run one at a time. Use Promise.all() for concurrent independent operations.
Promise Combinators: Beyond Single Promises
Promise.all([p1, p2, p3]) runs all Promises concurrently and resolves when all resolve (or rejects immediately if any reject). Use it for fetching multiple independent resources simultaneously — loading user data, permissions, and settings in parallel instead of sequentially. Promise.allSettled() runs all Promises concurrently and resolves when all complete regardless of success or failure — use it when you need results from all operations even if some fail. Promise.race() resolves or rejects with the first Promise to settle — useful for timeouts (race a fetch against a timeout Promise). Promise.any() resolves with the first successful Promise — useful for redundant requests to multiple endpoints.
Common Async Mistakes
Forgetting to await: const data = fetchData() stores a Promise, not the data — always await async function calls. Async forEach: Array.forEach doesn't await async callbacks — use for...of with await or Promise.all(array.map(async () => ...)). Unhandled Promise rejections: always attach .catch() to Promise chains or use try/catch in async functions. Missing error boundaries: in React, async errors in event handlers aren't caught by Error Boundaries — handle them with local state. Download async JavaScript patterns and React data fetching templates at proofmatcher.com.
Sequential vs Parallel: The Most Common Performance Mistake
Every await pauses the function until that promise settles. Writing const user = await getUser(); const posts = await getPosts(); runs the two requests one after the other, even though neither depends on the other. If each takes 300 milliseconds, the function takes 600. Start both first and await them together instead: const [user, posts] = await Promise.all([getUser(), getPosts()]);. The same applies to loops. An await inside a for loop processes items one at a time, which is correct when order matters or when you must not overload an API, but slow otherwise. To process a list in parallel, map it to promises and await Promise.all; to limit concurrency, process the list in small batches or use a small queue helper.
Error Handling Patterns That Scale
Wrapping every line in try/catch quickly becomes noisy. Handle errors at the level where you can actually do something about them: show a message in the UI, retry, or return a fallback value. Let lower-level functions throw, and catch in the component, route handler, or job runner that owns the operation. Always rethrow errors you cannot handle rather than swallowing them silently, and use finally for cleanup such as hiding a loading indicator. When you run several operations and want every result even if some fail, Promise.allSettled returns an array describing each outcome, so one failed request does not discard the others.
Unhandled rejections are errors nobody caught. In the browser they appear in the console; in Node.js they crash the process by default. Make sure every promise chain ends in an await inside a try block or a .catch(), and do not start promises you never await unless you deliberately attach error handling to them.
Cancellation and Timeouts with AbortController
Promises cannot be cancelled on their own, but many APIs, including fetch, accept an AbortSignal. Create an AbortController, pass controller.signal to the request, and call controller.abort() when the result is no longer needed, for example when a user types a new search term or leaves the page. The aborted request rejects with an AbortError, which you can recognise and ignore. For timeouts, AbortSignal.timeout(5000) creates a signal that aborts automatically after five seconds, and AbortSignal.any() combines several signals, such as a user cancel button and a timeout.
Retries with Backoff
Network requests fail temporarily. For idempotent operations such as reading data, retry a few times with an increasing delay, for example 500 milliseconds, then one second, then two, plus a little random jitter so many clients do not retry at the same moment. Do not retry errors that will never succeed, such as validation failures or a 404, and be careful retrying operations that create or charge something unless the API supports idempotency keys.
Understanding the Event Loop
Knowing what runs when explains many surprising bugs. Synchronous code always finishes first. Then the microtask queue runs, which includes promise callbacks and the code after each await. Only after all microtasks are done does the event loop take the next task, such as a timer, a network event, or a click. This is why a setTimeout(fn, 0) runs after an already resolved promise's .then(), and why a very long chain of synchronous work or microtasks can freeze the page. If you need to process a large amount of data in the browser, split it into chunks and yield to the event loop between them, or move the work to a Web Worker.
Modern Helpers Worth Knowing
- Top-level await works in ES modules, so a module can
awaitconfiguration or data before it exports anything. for await...ofiterates over async iterables, such as streamed responses or paginated API helpers, one item at a time.Promise.withResolvers(), added in ES2024, returns a promise together with itsresolveandrejectfunctions, which simplifies wrapping event-based APIs.Promise.any()resolves with the first promise that succeeds, useful for trying several mirrors or data sources and using whichever answers first.
With these patterns, async code stays fast, predictable, and easy to debug as your application grows.
Testing Async Code
Async bugs often hide in the paths that rarely run, such as timeouts and failures. In tests, always await the function under test or return its promise, otherwise the test finishes before the assertion runs and passes by accident. Use your test runner's rejects helper to assert that a promise fails with the expected error, and mock network calls so you can simulate slow responses, errors, and aborted requests deterministically. Fake timers, available in both Jest and Vitest, let you test retry delays and timeouts instantly instead of waiting for real seconds to pass. Covering these edge cases in tests is far cheaper than discovering them in production logs.