JavaScript • Asynchronous JavaScript

Error Handling in Async Code — Handle errors in async code (try/catch with await)

Learn to gracefully catch and manage errors in asynchronous JavaScript using try/catch with async/await.

JavaScript emblem with code brackets, braces, and a code file.
Inside this lesson 10 sections 5 questions 6 figures

SECTION 1

Historical Context & Motivation

JavaScript was originally designed in 1995 as a simple scripting language for web browsers, and almost everything happened synchronously — one line finished before the next one began. But as web applications grew more complex, developers needed to request data from servers, read files, and wait for user actions without freezing the entire page. This need gave rise to asynchronous programming, where code could start a task and move on while waiting for the result. The challenge, however, was figuring out what to do when those background tasks failed.

In synchronous code, handling errors was straightforward — you wrapped risky code in a try/catch block and JavaScript would jump to the catch section if something went wrong. But early asynchronous patterns like callbacks broke this model entirely, because errors happened at a later time, outside the original try/catch scope. Developers had to pass error-handling functions around manually, leading to messy, deeply nested code known as callback hell.

1995 • JavaScript Created

Brendan Eich creates JavaScript with synchronous try/catch error handling and callback-based async patterns.

2009 • Node.js & Error-First Callbacks

Node.js popularizes the error-first callback convention, where the first argument to every callback is an error object. This works but leads to deeply nested code.

2015 • Promises Arrive (ES6)

ECMAScript 2015 introduces Promises with .then() and .catch() methods, giving async errors a structured home.

2017 • Async/Await (ES2017)

ECMAScript 2017 introduces async/await, finally allowing developers to use familiar try/catch blocks with asynchronous code.

2020+ • Modern Best Practices

Async/await with try/catch becomes the standard pattern for async error handling in both front-end frameworks and server-side Node.js applications.

The arrival of async/await in 2017 was a turning point. It answered a critical question: How can we handle errors in asynchronous code as cleanly as we do in synchronous code? The answer was elegant — make async code look and behave like sync code, so try/catch works naturally again.

SECTION 2

Core Principles & Definitions

Before diving into the syntax, you need to understand a few foundational ideas that make async error handling work. Each concept builds on the previous one, so take them in order.

1

Async Functions

Adding the async keyword before a function declaration tells JavaScript that this function will work with Promises. An async function always returns a Promise, even if you return a plain value.
2

The await Keyword

The await keyword pauses the async function's execution until a Promise settles. If the Promise resolves, await returns the value. If it rejects, it throws an error.
3

try/catch Blocks

The try block wraps code that might fail. If an error occurs inside try, JavaScript immediately jumps to the catch block, giving you the error object to inspect and handle.
4

The finally Block

The optional finally block runs after try and catch regardless of whether an error occurred. It is perfect for cleanup tasks like hiding a loading spinner.
5

Error Objects

When a Promise rejects or code throws, JavaScript creates an Error object with useful properties: message (what went wrong), name (error type), and stack (where it happened).
✦

KEY TAKEAWAY

Think of try/catch with await like a safety net under a tightrope walker. The tightrope walk is your async operation — fetching data, reading a file, or calling an API. Most of the time, the walker makes it across (the Promise resolves). But when they slip (the Promise rejects), the safety net (the catch block) catches them so the entire show doesn't crash. Without the net, one slip and the whole program goes down.

SECTION 3

Visual Explanation — How try/catch Works with await

The following diagram illustrates the flow of an async function that uses try/catch with await. Follow the arrows to see what happens when the Promise resolves successfully versus when it rejects with an error.

Async/Await Error Handling Flowasync function()try { await somePromise() }Promise settles?Resolves ✓Rejects ✗const data = resultUse returned valuecatch (error) { }Handle the errorContinue executionLog / display / retryfinally { cleanup }
The async function enters the try block and pauses at await. If the Promise resolves (green path), execution continues normally. If it rejects (red path), control jumps to catch. Either way, the finally block always runs at the end.

Notice that the diagram shows two distinct paths that converge at the finally block. This is one of the most powerful aspects of try/catch/finally: no matter what happens — success or failure — you have a guaranteed place to run cleanup code. This is especially useful for things like stopping a loading animation or closing a database connection.

SECTION 4

How It Works — The Mechanics of try/catch with await

Let's look at the actual code structure. When you mark a function with async, JavaScript knows that any await expression inside it should pause execution until the awaited Promise settles. If that Promise rejects, the rejection is automatically converted into a thrown error, which means try/catch can intercept it just like any synchronous error.

Basic Pattern

BASIC ASYNC ERROR HANDLING PATTERN

asyncfunctionfetchData()tryconstresponse=awaitfetch(url);constdata=awaitresponse.json();returndata;catch(error)console.error(′Failed:′,error.message);finallyconsole.log(′Donefetching.′);async function fetchData() { try { const response = await fetch(url); const data = await response.json(); return data; } catch (error) { console.error('Failed:', error.message); } finally { console.log('Done fetching.'); } }

async — marks the function as asynchronous. await — pauses until the Promise resolves or rejects. catch (error) — receives the rejection reason as the error parameter. finally — runs regardless of success or failure.

What Happens Under the Hood

When you write await somePromise() inside a try block, JavaScript is essentially doing this behind the scenes: it calls somePromise().then(result => ...) for the success case and .catch(error => { throw error }) for the failure case. The thrown error then triggers the catch block, just as if you had written throw new Error() directly in synchronous code.

EQUIVALENT PROMISE CHAIN (FOR COMPARISON)

functionfetchData()returnfetch(url).then(response=>response.json()).then(data=>data).catch(error=>console.error(′Failed:′,error.message)).finally(()=>console.log(′Donefetching.′));function fetchData() { return fetch(url) .then(response => response.json()) .then(data => data) .catch(error => console.error('Failed:', error.message)) .finally(() => console.log('Done fetching.')); }

This Promise chain version does the same thing as the async/await version above. Notice how the try/catch version reads more like regular top-to-bottom code.

⚠

Common Mistake

If you forget the await keyword, the try/catch block won't catch async errors! Without await, the function receives a pending Promise object instead of waiting for the result. The error fires later, outside the try block's scope, and goes uncaught.

SECTION 5

Common Error Handling Patterns

Now that you understand the basic mechanism, let's explore the most common patterns you'll encounter when handling errors in real async code. Each pattern is suited for different situations, and knowing when to use each one will make your code more robust and readable.

Three Common Async Error Handling PatternsPattern 1Wrap the Whole Blocktry { await step1(); await step2(); await step3();} catch (err) { handleError(err)}✓ Simple & clean✓ Best for related steps✗ Can't distinguish whichstep caused the errorPattern 2Individual try/catchtry { await step1();} catch(e){handle1()}try { await step2();} catch(e){handle2()}✓ Precise error control✓ Different handling per step✗ More verbose codePattern 3Error Type Checkingtry { await fetchData();} catch (err) { if (err instanceof TypeError) { ... } else { ... }}✓ Handle different errortypes differently✓ Single catch block✗ Requires type knowledge
Pattern 1 wraps all async calls in one try/catch — simple but imprecise. Pattern 2 uses separate try/catch blocks for each call — verbose but precise. Pattern 3 uses instanceof checks inside a single catch to route different error types.

In practice, Pattern 1 is the most common for straightforward operations like fetching data from an API. You use Pattern 2 when each step needs its own fallback logic — for example, if failing to load a profile picture shouldn't prevent loading the user's name. Pattern 3 shines when your API might throw different types of errors (network errors, validation errors, authentication errors) and each type requires a different response.

SECTION 6

Worked Example — Fetching User Data

Let's walk through a realistic example. Imagine you're building a web app that fetches a user's profile from an API. The API might be down, the user might not exist, or the response might not be valid JSON. We need to handle all of these cases gracefully.

Fetching User Profile with Error Handling

  1. 1

    Step 1 — Define the async function

    We declare our function with the async keyword. This tells JavaScript the function will contain await expressions and will return a Promise.
    async function getUserProfile(userId) {
  2. 2

    Step 2 — Open the try block and fetch data

    Inside the try block, we use await fetch() to make the HTTP request. The await pauses execution until the fetch completes. If the network is down, this line throws an error and jumps straight to catch.
    const response = await fetch(`/api/users/${userId}`);
  3. 3

    Step 3 — Validate the response

    The fetch() API doesn't throw an error for HTTP 404 or 500 status codes — it only rejects on network failures. So we need to check response.ok manually and throw our own error if the status indicates a problem.
    if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); }
  4. 4

    Step 4 — Parse the JSON

    We use await response.json() to parse the response body as JSON. This is another async operation that could fail if the server sends back malformed data. If parsing fails, the error is caught by the same catch block.
    const user = await response.json(); return user;
  5. 5

    Step 5 — Handle errors in the catch block

    The catch block receives the error object. We log the error for debugging and return a fallback value so the rest of the application doesn't crash. We could also show the user a friendly message instead.
    catch (error) { console.error('Failed to fetch user:', error.message); return null; }
  6. 6

    Step 6 — Add a finally block for cleanup

    The finally block runs after either success or failure. Here we hide a loading spinner that we might have shown before starting the fetch.
    finally { hideLoadingSpinner(); }
💡

Pro Tip

Always provide a fallback return value (like null or a default object) in your catch block. This way, the code that called your async function always gets a predictable response, even when things go wrong.

SECTION 7

Comparing Error Handling Approaches

JavaScript offers multiple ways to handle async errors. Each approach has trade-offs, and understanding them helps you choose the right tool for each situation. The table below compares the three main approaches side by side.

FeatureCallbacks (error-first).then()/.catch()try/catch with await
ReadabilityPoor — deeply nested, hard to followModerate — chain-based, linearExcellent — reads like synchronous code
Error locationMust check error in every callbackOne .catch() at end of chainOne catch block for whole try scope
Cleanup logicManual — must remember to add it.finally() method availablefinally { } block — very clean
Stack tracesOften broken or incompleteBetter but can be confusingClear and accurate
Learning curveLow syntax, high cognitive loadModerate — must understand chainingLow — familiar if you know try/catch
Browser supportAll versionsES6+ (2015)ES2017+ (all modern browsers)

Comparison of JavaScript async error handling approaches

✦

KEY TAKEAWAY

The try/catch approach with await is now the recommended pattern in modern JavaScript. It combines the familiarity of synchronous error handling with the power of Promises. Think of it this way: .then()/.catch() is like texting back and forth to solve a problem — it works but can get messy. try/catch with await is like having a face-to-face conversation — the flow is natural and easy to follow.

SECTION 8

Connection to Advanced Concepts

Once you're comfortable with basic try/catch in async functions, you'll encounter more advanced patterns that build on the same foundation. These are concepts you'll see in professional codebases and more advanced courses.

This LessonAdvanced Concept
Single await in try/catchPromise.all() with try/catch — run multiple async operations in parallel and catch if any fail
Catching any errorCustom error classes — create class NetworkError extends Error for more specific handling
Logging errors with console.error()Global error handlers like window.addEventListener('unhandledrejection') to catch forgotten errors
Return fallback in catchRetry logic — automatically re-attempt failed requests with exponential backoff
finally for cleanupAbortController — cancel ongoing async operations when they're no longer needed

How this lesson's concepts connect to advanced async patterns

One particularly important advanced concept is Promise.allSettled(), which lets you run multiple Promises at the same time and get results from all of them — even if some fail. Unlike Promise.all(), which rejects immediately when any Promise fails, Promise.allSettled() waits for everything to finish and tells you which ones succeeded and which ones failed. This is powerful for situations like loading a dashboard where you want to show whatever data is available, even if some API calls fail.

ℹ

Looking Ahead

As you advance, you'll also encounter error handling in frameworks like React (Error Boundaries), Node.js (middleware error handlers in Express), and TypeScript (typed error handling). All of these build on the try/catch fundamentals you're learning now.

SECTION 9

Practice Problems

Question 1: CONCEPTUAL

Explain in your own words why a regular try/catch block cannot catch errors from a Promise unless you use await. What happens to the error if you forget the await keyword?
Show answer
Without await, the async operation starts but the try block finishes executing immediately — it doesn't wait for the Promise to settle. By the time the Promise rejects, execution has already left the try/catch scope. The error becomes an unhandled Promise rejection, which shows up as a warning in the console. The await keyword solves this by pausing the function so the try/catch block is still active when the error occurs.

Question 2: BASIC

Given the following code, identify the bug and fix it: async function loadData() { try { const data = fetch('/api/data'); console.log(data); } catch (err) { console.log('Error:', err.message); } }
Show answer
The bug is that fetch() is missing the await keyword. Without it, data will be a pending Promise object, not the actual response. Also, response.json() is needed to parse the body. The fix: const response = await fetch('/api/data'); const data = await response.json(); console.log(data);. Now the catch block will properly intercept any network errors or JSON parsing failures.

Question 3: INTERMEDIATE

Write an async function called getWeather(city) that fetches weather data from `/api/weather/${city}`. It should: (1) throw a custom error if response.ok is false, (2) parse and return the JSON, (3) log the error message if anything goes wrong, and (4) always log 'Weather request complete' using a finally block.
Show answer
Here is the solution: async function getWeather(city) { try { const response = await fetch(`/api/weather/${city}`); if (!response.ok) { throw new Error(`Weather API error: ${response.status}`); } const data = await response.json(); return data; } catch (error) { console.error('Weather fetch failed:', error.message); return null; } finally { console.log('Weather request complete'); } } This function uses all three blocks. The try block handles the happy path with both the fetch and the manual status check. The catch logs errors and returns a safe fallback. The finally ensures the log message appears regardless of the outcome.

Question 4: APPLIED

You're building a student grade viewer that fetches grades from two different APIs: /api/math-grades and /api/english-grades. If one API fails, the other grades should still display. Write an async function that fetches both using separate try/catch blocks and returns an object with { mathGrades, englishGrades }, using null for any that fail.
Show answer
This problem requires Pattern 2 (individual try/catch blocks): async function getAllGrades() { let mathGrades = null; let englishGrades = null; try { const res = await fetch('/api/math-grades'); mathGrades = await res.json(); } catch (err) { console.error('Math grades failed:', err.message); } try { const res = await fetch('/api/english-grades'); englishGrades = await res.json(); } catch (err) { console.error('English grades failed:', err.message); } return { mathGrades, englishGrades }; } By using separate try/catch blocks, a failure in fetching math grades doesn't prevent the function from attempting to fetch English grades. Each API call can fail independently, and the returned object uses null for any data that couldn't be retrieved.

Question 5: CRITICAL THINKING

Consider this code: async function riskyOperation() { try { const a = await stepA(); const b = await stepB(a); const c = await stepC(b); return c; } catch (error) { return 'default'; } } If stepB() fails, does stepC() still run? Explain the execution flow. Then, discuss whether the single catch block is a strength or weakness in this scenario. How would you improve the error handling to give the user more specific information about what failed?
Show answer
No, stepC() does not run. When stepB() rejects, the await throws the rejection as an error, and execution immediately jumps to the catch block — skipping everything else in the try block. The single catch block is a weakness here because the user just gets 'default' with no information about whether stepA, stepB, or stepC failed. To improve this, you could use error type checking (Pattern 3) by checking error.message or instanceof to provide specific messages. Alternatively, you could wrap each step in its own try/catch (Pattern 2), or throw custom errors from each step that include context about which operation failed. The best approach depends on whether the steps are independent (use separate try/catch) or sequential dependencies (use single try/catch with descriptive errors).

SUMMARY

Summary

Summary

Handling errors in asynchronous JavaScript has evolved from messy callback-based patterns to the clean, readable approach of try/catch with async/await. An async function always returns a Promise, and the await keyword pauses execution until a Promise settles. When a Promise rejects inside a try block, the rejection is converted into a thrown error that the catch block intercepts, giving you access to the error object with its message, name, and stack trace properties.

You learned three common patterns: wrapping a whole block in a single try/catch for simple operations, using individual try/catch blocks when steps are independent, and using error type checking with instanceof to route different errors. The finally block guarantees cleanup code runs regardless of success or failure. Remember: always use await inside try — without it, errors escape the catch block entirely.

Varsity Tutors • JavaScript • Error Handling in Async Code — Handle errors in async code (try/catch with await)