TL;DR

The async keyword marks a function as asynchronous — it returns a Promise. The await keyword pauses that function until a Promise resolves, then gives you the value. Together, they let JavaScript wait for slow API calls, database reads, and file operations without freezing the browser or the server. AI uses this pattern for virtually every external data operation.

Why AI Coders Need to Know This

When you ask Claude Code to "add a weather widget" or Cursor to "fetch the user's profile from the API," the AI is going to generate async functions. Guaranteed. There's no way around it — any time your code needs to wait for something that takes time, async/await is the tool JavaScript uses.

The problem is that async code is the number one source of mysterious bugs for people building with AI. You see Promise {<pending>} in your console. You get "Cannot read properties of undefined" on data that clearly should be there. Your weather widget shows nothing, no error — it just silently fails.

These aren't random bugs. They follow predictable patterns, and once you understand what async and await actually mean, you'll recognize them instantly — and know exactly how to fix them or prompt AI to fix them.

Beyond debugging, understanding async/await changes how you prompt. Instead of "fetch the weather data and show it," you can say: "write an async function that fetches weather from Open-Meteo, awaits the JSON response, handles network errors with try/catch, and returns a structured object with temperature and description." That prompt generates production-ready code on the first try.

The Core Problem: JavaScript Can Only Do One Thing at a Time

Here's something that surprises a lot of people: JavaScript is single-threaded. That means it can only execute one piece of code at a time. There's one lane of traffic, not eight.

This is usually fine — JavaScript runs fast enough that single-threaded code feels instant. But there's a category of operations that are genuinely slow: network requests, database queries, reading files, waiting for timers. These can take milliseconds to seconds.

If JavaScript waited synchronously for a network request — literally stopping everything until the data arrived — your entire page would freeze. The browser couldn't update the UI, respond to clicks, or do anything until that request completed. On a slow connection, your page might freeze for several seconds every time it fetched data.

This is the problem async code solves. Instead of freezing and waiting, JavaScript says: "I'll kick off this slow operation, then continue doing other things. When the slow operation finishes, let me know and I'll handle the result."

A Brief History: Callbacks → Promises → async/await

JavaScript has solved this problem three different ways over the years. You'll encounter all three in AI-generated code, so it's worth knowing they exist:

  • Callbacks (old way, 1995–2015): Pass a function as an argument. That function gets called when the slow thing finishes. This led to "callback hell" — deeply nested functions that were nearly impossible to read or debug.
  • Promises (better way, ES2015): Objects that represent a future value. You chain .then() and .catch() to handle success and failure. Much cleaner than callbacks, but still a bit verbose.
  • async/await (modern way, ES2017): Syntax built on top of Promises that reads like normal synchronous code. AI tools use this almost exclusively today because it's the most readable and maintainable approach.

Under the hood, async/await is still Promises. But you don't have to chain .then() — you just write code that looks sequential, and JavaScript handles the asynchronous machinery for you.

Real Scenario: You Asked Claude Code to Add a Weather Widget

You're building a personal site and you want a live weather widget in the corner. You open Claude Code and type:

Your Prompt to Claude Code

"Add a weather widget to my site. It should fetch the current weather for a city from the Open-Meteo API (free, no API key needed) and display the temperature and a description. Handle errors gracefully if the fetch fails."

Claude Code generates a JavaScript file. Here's what it looks like, with every line annotated:

What AI Generated

// Weather Widget — generated by Claude Code
// Tested with: JavaScript ES2024, Open-Meteo API, March 2026

// The 'async' keyword transforms this into an async function.
// It will always return a Promise, even if you don't explicitly return one.
async function fetchWeather(city) {

  // 'try' wraps the code that might fail. If any 'await' inside throws,
  // we jump to the 'catch' block instead of crashing the whole page.
  try {

    // Step 1: Get the latitude/longitude for the city name
    // 'await' pauses here until the fetch completes and we have a Response object
    const geoResponse = await fetch(
      `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`
    );

    // Step 2: Parse the Response body as JSON
    // 'await' pauses again — response.json() is also async (reading the body takes time)
    const geoData = await geoResponse.json();

    // Step 3: Check if the city was found
    if (!geoData.results || geoData.results.length === 0) {
      throw new Error(`City not found: ${city}`);
    }

    const { latitude, longitude, name } = geoData.results[0];

    // Step 4: Fetch the actual weather using the coordinates
    const weatherResponse = await fetch(
      `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t_weather=true`
    );

    // Step 5: Parse the weather response as JSON
    const weatherData = await weatherResponse.json();

    // Step 6: Extract what we need and return a clean object
    const { temperature, weathercode } = weatherData.current_weather;

    return {
      city: name,
      temperature: `${temperature}°C`,
      description: getWeatherDescription(weathercode),
      success: true
    };

  } catch (error) {
    // 'catch' handles ANY error from the try block —
    // network failures, bad JSON, missing data, our own thrown errors
    console.error('Weather fetch failed:', error.message);

    return {
      city: city,
      temperature: '--',
      description: 'Unable to load weather data',
      success: false
    };

  } finally {
    // 'finally' always runs — whether the try succeeded or catch fired
    // Good for cleanup: hiding loading spinners, unlocking UI elements
    console.log(`Weather fetch attempt complete for: ${city}`);
  }
}

// Helper function to convert WMO weather code to human-readable description
function getWeatherDescription(code) {
  const descriptions = {
    0: 'Clear sky',
    1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast',
    45: 'Fog', 48: 'Depositing rime fog',
    51: 'Light drizzle', 53: 'Moderate drizzle', 55: 'Dense drizzle',
    61: 'Slight rain', 63: 'Moderate rain', 65: 'Heavy rain',
    71: 'Slight snow', 73: 'Moderate snow', 75: 'Heavy snow',
    80: 'Slight showers', 81: 'Moderate showers', 82: 'Violent showers',
    95: 'Thunderstorm'
  };
  return descriptions[code] || 'Unknown conditions';
}

// Call the function and update the DOM with the result
// Note: we MUST use await here, or we get a Promise object, not the weather data
async function displayWeather() {
  const widget = document.querySelector('#weather-widget');
  widget.textContent = 'Loading...';

  const weather = await fetchWeather('Seattle');

  if (weather.success) {
    widget.innerHTML =
      `<strong>${weather.city}</strong>: ${weather.temperature} — ${weather.description}`;
  } else {
    widget.textContent = weather.description;
  }
}

// Run it when the page loads
document.addEventListener('DOMContentLoaded', displayWeather);

Understanding Each Part

The async Keyword

Putting async before a function does two things:

  1. It makes the function always return a Promise. If you return a plain value like return 42, JavaScript automatically wraps it: return Promise.resolve(42). If the function throws an error, it returns Promise.reject(error).
  2. It allows the use of await inside the function. You cannot use await outside an async function (except at the top level of ES modules, but that's an advanced topic).

That's it. async is a declaration: "this function works with Promises, and it will await them internally."

The await Keyword

await can only appear inside an async function. When JavaScript hits an await expression, it:

  1. Starts the Promise (kicks off the network request, file read, etc.)
  2. Pauses that function's execution — but crucially, not the whole JavaScript engine
  3. Goes off to do other work (handle other events, run other code)
  4. When the Promise resolves, resumes the function and gives you the resolved value

This is the key insight: await pauses your function, not the browser. Other code keeps running. The UI stays responsive. JavaScript just picks back up your function where it left off when the data is ready.

The Three States of a Promise

Every Promise is always in one of three states. Understanding these is the key to reading error messages and console output:

Pending The operation is in progress. Not done yet. This is what you see if you log a Promise before it resolves.
Fulfilled The operation succeeded. The Promise has a value. await unwraps this value for you automatically.
Rejected The operation failed. The Promise has an error reason. catch handles this state.

When you see Promise {<pending>} in the console, a Promise is in the first state. When you see Promise {<fulfilled>: 42}, it's in the second state. Both tell you the same thing: you forgot to await before printing the result.

fetch() — The Built-In HTTP Client

fetch() is JavaScript's built-in function for making HTTP requests. When you call fetch(url), it immediately returns a Promise that represents the eventual HTTP response. It doesn't give you the data right away — the network request is still in flight.

await fetch(url) pauses until the HTTP response headers arrive and gives you a Response object. This Response object tells you the status code (200, 404, 500, etc.) and has methods to read the body.

Then await response.json() is a second await — because reading the response body is also async. The body is a stream that may not be fully downloaded yet. .json() reads the entire body and parses it as JSON, returning a Promise that resolves to the parsed JavaScript object.

Common Gotcha

A fetch() call only rejects (throws an error) if there's a network failure — no connection, DNS failure, CORS block. It does not reject for HTTP error codes like 404 or 500. Your code needs to check response.ok or response.status to handle those cases. This surprises almost every developer the first time.

try/catch/finally — Handling What Goes Wrong

With synchronous code, errors propagate up the call stack until something catches them. With async code and Promises, the error becomes a rejected Promise. try/catch inside an async function catches those rejections just like it catches synchronous errors.

  • try — the block of code to attempt. If anything inside throws or awaits a rejected Promise, execution jumps immediately to catch.
  • catch(error) — receives the error object. The error.message property tells you what went wrong. Log it. Return a safe fallback value. Never silently swallow errors.
  • finally — runs regardless of whether try succeeded or catch fired. Perfect for cleanup code: hiding loading spinners, re-enabling buttons, closing connections.

What AI Gets Wrong About async/await

1. Forgetting await — The Most Common Bug

This is the bug that generates the most confused "my code is broken" messages. When you call an async function without await, you get back a Promise object — not the data. The Promise might already be fulfilled (the data is there), but you're holding the wrapper, not the contents.

❌ Missing await

async function displayWeather() {
  // No await — weather is a Promise!
  const weather = fetchWeather('Seattle');

  // weather.city === undefined
  // weather is Promise {fulfilled}
  console.log(weather.city);
  // → undefined

  // The console shows:
  // Promise {<fulfilled>: {city: ...}}
}

✅ With await

async function displayWeather() {
  // await unwraps the Promise
  const weather = await fetchWeather('Seattle');

  // weather is now the actual object
  console.log(weather.city);
  // → "Seattle"
}

If you see Promise {<pending>} or Promise {<fulfilled>: ...} when you expected actual data, the fix is almost always: add await.

2. No Error Handling — Silent Failures

AI sometimes generates async functions without any try/catch. When the network request fails — because the user is offline, the API is down, or the URL is wrong — the function throws an unhandled Promise rejection. The feature silently stops working. The user sees nothing. You get a warning in the console that's easy to miss.

❌ No Error Handling

async function fetchWeather(city) {
  // If this fails, the whole function
  // crashes silently in production
  const response = await fetch(url);
  const data = await response.json();
  return data;
}
// Unhandled Promise rejection:
// TypeError: Failed to fetch

✅ With try/catch

async function fetchWeather(city) {
  try {
    const response = await fetch(url);
    const data = await response.json();
    return { success: true, data };
  } catch (error) {
    console.error('Fetch failed:', error);
    return { success: false, error: error.message };
  }
}

3. Using async Inside forEach Loops

This one trips up even experienced developers. forEach doesn't understand async/await. If you put await inside a forEach callback, the awaits technically run — but forEach doesn't wait for them to finish before moving to the next item. You lose sequential control and the code becomes unpredictable.

❌ async in forEach

// This does NOT wait for each
// item before moving to the next
const cities = ['Seattle', 'Portland', 'Denver'];

cities.forEach(async (city) => {
  // All three fetches fire at once!
  const weather = await fetchWeather(city);
  console.log(weather);
  // Results arrive in random order
});

✅ Use for...of instead

// for...of correctly awaits each item
const cities = ['Seattle', 'Portland', 'Denver'];

for (const city of cities) {
  const weather = await fetchWeather(city);
  console.log(weather);
  // Results arrive in order, one by one
}

// Or fetch all at once with Promise.all:
const results = await Promise.all(
  cities.map(city => fetchWeather(city))
);

4. Not Checking response.ok for HTTP Errors

As noted above, fetch() only throws on network failures — not on HTTP error codes. A 404 Not Found or 500 Server Error response still resolves the Promise successfully. AI often skips this check, meaning your code happily tries to parse a "404 page not found" HTML error page as JSON, producing cryptic errors.

// Always check response.ok after fetch
const response = await fetch(url);

if (!response.ok) {
  // This catches 404, 500, 429, 403, etc.
  throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
}

// Now it's safe to parse JSON
const data = await response.json();

5. Blocking the UI by Awaiting in the Wrong Place

Even with async/await, it's possible to create a bad user experience if you await something at a point that makes the UI feel unresponsive. The classic mistake: showing nothing on the page until all async data is loaded, instead of showing a loading state immediately.

// Pattern: Show loading state immediately,
// then update when data arrives
async function displayWeather() {
  const widget = document.querySelector('#weather-widget');

  // Show loading state RIGHT AWAY — before any await
  widget.innerHTML = '<span class="loading">Loading weather...</span>';

  // Now await the data
  const weather = await fetchWeather('Seattle');

  // Update the UI when data arrives
  widget.textContent = `${weather.city}: ${weather.temperature}`;
}

How to Debug async/await with AI Tools

"Promise {<pending>}" in the Console

This is the clearest signal that you forgot await. When you see this, trace back to where you called the async function and add await before it. Remember: the function that uses await must itself be async.

Debugging Prompt for Claude Code

"My console shows 'Promise {pending}' instead of the weather data. Here's my code: [paste code]. I think I'm missing an await somewhere. Can you identify where and show me the corrected version?"

"Cannot read properties of undefined" After an Await

This usually means the data arrived but it doesn't have the structure you expected. The API returned an error object, or the JSON has different field names than what you're accessing. The fix:

  1. Log the raw response: console.log('API response:', data) immediately after your await response.json()
  2. Check the actual shape of the object — field names, nesting, arrays vs objects
  3. Tell Cursor: "The API returned this JSON: [paste JSON]. My code expects [describe what you're accessing]. Update the code to match the actual response shape."

Async Debugging in Chrome DevTools

Chrome DevTools has first-class async debugging. In the Sources panel, when paused at a breakpoint inside an async function, you'll see the full async call stack — not just the current stack frame, but the chain of async calls that led here. Enable it in DevTools → Sources → Settings → "Enable JavaScript source maps" and "Async stack traces."

For network-specific debugging, the Network panel in DevTools shows every fetch() call. You can see the request URL, status code, response headers, and the full response body. If your fetch is returning a 401, 404, or 500, the Network panel shows it clearly — even when the console shows nothing.

Windsurf Tip

In Windsurf, you can paste a network response directly into the chat: "Here's the actual JSON my API returned: [paste]. Here's the code that processes it: [paste]. Why am I getting undefined on line X?" Windsurf can spot the mismatch between what the API returns and what the code expects.

Reading Unhandled Promise Rejection Warnings

If you see Uncaught (in promise) Error: ... in the console, an async function rejected without a catch. The error message tells you what went wrong. Wrap the offending code in try/catch, or chain .catch() if you're calling the function from non-async code:

// If you can't make the calling scope async:
displayWeather()
  .catch(error => console.error('displayWeather failed:', error));

// Or make the caller async:
async function init() {
  try {
    await displayWeather();
  } catch (error) {
    console.error('displayWeather failed:', error);
  }
}

What to Learn Next

async/await is built on top of functions — and specifically, functions that return values. If you haven't read our function explainer, start there to cement the foundation. Variables are also essential, since async code stores Promise results in variables constantly.

The most important real-world application of async/await is working with APIs. Once you're comfortable with the async pattern, the natural next step is understanding what an API actually is and how to work with the data it returns.

Frequently Asked Questions

The async keyword makes a function always return a Promise. The await keyword pauses execution inside that function until a Promise resolves, then hands you the resolved value. Together, they let you write code that waits for slow operations (like API calls or database reads) without freezing the browser — and without the complex callback nesting that was required before.

Promises and async/await do the same thing — they are both ways to handle asynchronous operations in JavaScript. async/await is syntax built on top of Promises. Under the hood, every async function returns a Promise, and every await expression resolves a Promise. async/await just makes the code read more like normal synchronous code, which is why AI tools default to it. You still need to understand Promises because errors from async functions are rejected Promises, and many APIs return Promises directly.

You forgot await. When you call an async function without await, you get back a Promise object — not the resolved value. The Promise may still be pending (waiting for data) or fulfilled (data is ready), but without await, JavaScript doesn't pause to unwrap it. Fix: add await before the function call, and make sure the calling code is itself inside an async function. Example: const data = await fetchWeather('Seattle') instead of const data = fetchWeather('Seattle').

If an await expression throws an error (for example, a failed network request or a bad API response) and there is no try/catch, the error propagates as an unhandled Promise rejection. In browsers, this shows up as an "Unhandled Promise Rejection" warning in the console, and the rest of the function stops executing. This can silently break features — the user sees nothing, and you get no error message unless you have DevTools open. Always wrap async operations in try/catch.

No — not if you want to actually wait for each iteration. forEach does not understand async/await. If you put await inside a forEach callback, the awaits run but forEach doesn't wait for them, so your loop effectively becomes unordered and unpredictable. Use a regular for...of loop instead: for (const item of items) { await doSomething(item); } — this correctly waits for each async operation before moving to the next item.