Skip to content

Async JavaScript

docs.scrimba.com

Some things happen instantly: adding two numbers, reading a variable, changing text on the page. Others take time. A timer counts down for three seconds. A request to a server travels across the network and comes back a moment later. If JavaScript stopped and waited for each of those, the whole page would freeze: no clicks, no scrolling, nothing, until the slow thing finished. It does not work that way. This chapter is about how JavaScript starts something slow, keeps the page responsive, and comes back to handle the result when it is ready.

Why async

Most JavaScript runs top to bottom, one line finishing before the next starts. That is fine for fast work. But some tasks take real time, and JavaScript does not sit and wait for them. It starts the slow task, moves on to the next line, and comes back to the slow one when it is done. That is what asynchronous means: not happening in order, not right now, but later, when the result is ready.

The simplest example is a timer. setTimeout runs a piece of code after a delay:

js
console.log("Start");
setTimeout(() => {
  console.log("3 seconds later");
}, 3000);
console.log("End");
// Start
// End
// 3 seconds later

Notice the order. "End" prints before "3 seconds later", even though it comes after setTimeout in the code. JavaScript did not wait three seconds on that line. It set the timer, kept going, and ran the delayed code when the time was up.

JavaScript runs one thing at a time, top to bottom. That model works until a task takes time: a timer, a network request, reading a file. If the language blocked on those, the page would freeze while it waited, because nothing else could run. Asynchronous code is the answer: you start the slow task, JavaScript keeps running the rest of your code, and the result is handled later when it arrives.

setTimeout is the clearest first example. You give it a function and a delay in milliseconds, and it runs that function after the delay:

js
console.log("Start");
setTimeout(() => {
  console.log("3 seconds later");
}, 3000);
console.log("End");
// Start
// End
// 3 seconds later

The function you pass to setTimeout is a callback: code you hand off to be called later, not now. JavaScript registers the timer, moves straight to "End", and only runs the callback once the delay is up and the current code has finished. That "finish now, come back later" pattern is the whole idea of async, and everything else in this chapter builds on it.

JavaScript is single-threaded: one call stack, one thing executing at a time. That constraint is exactly why asynchrony matters. A blocking wait on a network request would stall the single thread, and with it every click, animation, and render on the page. So slow work is not done inline. It is handed to the runtime (the browser or Node), which does the waiting outside your thread and schedules your code to resume when the result is ready. Asynchronous code is that hand-off: you describe what to do with a result you do not have yet.

setTimeout is the minimal case, and it exposes the model cleanly:

js
console.log("Start");
setTimeout(() => {
  console.log("later");
}, 0);
console.log("End");
// Start
// End
// later

The delay here is 0, and the callback still runs last. That is the tell: async callbacks never run in the middle of synchronous code. setTimeout does not run its callback after zero milliseconds, it schedules the callback to run after the current synchronous code drains. The mechanism behind that ordering is the event loop, which the last section of this chapter takes apart. For now, hold the rule: synchronous code runs to completion first, then queued async work.

JunoWhy async Some tasks take time, like a timer or loading data, and JavaScript does not freeze while it waits. It starts the slow thing, keeps running the next lines, and comes back to the slow thing when it is ready. That is why "End" prints before the setTimeout message, even though the timer comes first in the code.
JunoWhy async Async means start the slow task now, handle its result later, and keep the page responsive in between. setTimeout is the simplest version: you hand it a callback and it runs that callback after the delay, not on the spot. Once you see why the delayed message prints last, the rest of this chapter is variations on that one idea.
JunoWhy async JavaScript is single-threaded, so blocking on slow work would stall everything. Instead you hand the wait to the runtime and describe what to do with the result when it lands. The setTimeout(fn, 0) case is the giveaway: the callback still runs after the synchronous code, because queued async work waits for the current code to finish first.

Promises

setTimeout is fine for a delay, but most async work produces a value you care about: the data you asked for, or an error if it went wrong. For that, JavaScript uses a promise. A promise is a value that is not ready yet, with a spot reserved for it. It is like a receipt: you do not have the meal, but you have something that will turn into the meal.

A promise ends in one of two ways. It resolves with a value when the work succeeds, or it rejects with an error when it fails. You say what to do in each case with .then() and .catch():

js
loadUser()
  .then((user) => {
    console.log(`Loaded ${user.name}`);
  })
  .catch((error) => {
    console.log("Something went wrong");
  });

.then() runs when the promise resolves, and it receives the value. .catch() runs if it rejects, and it receives the error. The code inside them runs later, once the result arrives.

Callbacks work, but they stack badly. When one async task depends on another, and that on another, you end up nesting callbacks inside callbacks, drifting further right with each step. That shape has a name, the "pyramid of doom", and it gets hard to read and harder to handle errors in. A promise fixes the shape.

A promise is an object standing in for a value that is not ready yet. It is in one of three states: pending (still working), fulfilled (resolved with a value), or rejected (failed with an error). You attach handlers with .then() for success and .catch() for failure:

js
loadUser()
  .then((user) => {
    console.log(`Loaded ${user.name}`);
    return loadPosts(user.id);
  })
  .then((posts) => {
    console.log(`Found ${posts.length} posts`);
  })
  .catch((error) => {
    console.log(`Failed: ${error.message}`);
  });

Two things make this better than nested callbacks. First, it is flat: each .then() returns a promise, so you chain them in a straight line instead of nesting. Return a value from a .then() and the next one receives it; return a promise and the chain waits for it. Second, one .catch() at the end handles a failure anywhere in the chain, so you write error handling once instead of at every level.

Before promises, async results were delivered through callbacks, and dependent async steps meant nesting callbacks inside callbacks. Beyond the readability cost, that pattern has no unified error path: every level handles its own failure, and forgetting one swallows the error silently. A promise solves both problems by making the eventual result a first-class value you can pass around, chain, and handle in one place.

A promise is an object in one of three states: pending, fulfilled with a value, or rejected with a reason. Once it settles (fulfilled or rejected) it never changes again. You register reactions with .then(onFulfilled) and .catch(onRejected), and the chaining is where the real power is:

js
loadUser()
  .then((user) => loadPosts(user.id)) // return a promise, chain waits for it
  .then((posts) => posts.filter((post) => post.published))
  .then((published) => console.log(`${published.length} published`))
  .catch((error) => console.log(`Failed: ${error.message}`));

Each .then() returns a new promise, and the handler's return value determines it: return a plain value and the next .then() receives that value, return a promise and the chain adopts it and waits. A thrown error or a rejection anywhere skips the remaining .then() handlers and jumps to the next .catch(), which is why one handler at the end catches failures from any step. This is a real improvement over callbacks, not only a nicer syntax: errors propagate automatically, and the flat chain reads in the order it runs. In practice you will read a lot of promise chains, but you will write most of your own async code with async/await, which is the next section and sits on top of exactly this machinery.

JunoPromises A promise is a value that is not ready yet, like a receipt for a result that arrives later. It resolves with a value when the work succeeds, or rejects with an error when it fails. You say what to do with .then() for the value and .catch() for the error, and both run later once the result is in.
JunoPromises A promise stands in for a result that is still coming, and it beats nested callbacks because you chain .then() in a flat line instead of drifting rightward. Return a value from a .then() and the next one gets it, return a promise and the chain waits. One .catch() at the end handles a failure anywhere in the chain, so you write error handling once.
JunoPromises A promise is an object that settles once, to fulfilled or rejected, and never changes after. Each .then() returns a new promise, so returning a value passes it on and returning a promise makes the chain wait, while any rejection skips ahead to the next .catch(). You will read plenty of chains, but write most of your own async with async and await, which sit right on top of this.

async / await

Promise chains work, but there is a cleaner way to write them that reads almost like ordinary top-to-bottom code. It uses two keywords, async and await.

You mark a function async, and inside it you can await a promise. await pauses the function until the promise resolves, then gives you the value directly, no .then() needed:

js
async function showUser() {
  const user = await loadUser();
  console.log(`Loaded ${user.name}`);
}

Read that top to bottom: get the user, then log the name. The await line waits for loadUser() to finish and hands you the user. The function pauses there, but the rest of the page keeps running, so nothing freezes.

To handle errors, wrap the await in try and catch:

js
async function showUser() {
  try {
    const user = await loadUser();
    console.log(`Loaded ${user.name}`);
  } catch (error) {
    console.log("Could not load the user");
  }
}

If the promise fails, the code jumps into the catch block instead of crashing.

async/await is the modern way to write async code. It is built on promises, so nothing underneath changes, but it lets you write async logic that reads like the synchronous code you already know from functions.

Mark a function async and you can use await inside it. await takes a promise, pauses the function until it resolves, and evaluates to the resolved value:

js
async function showUser() {
  const user = await loadUser();
  const posts = await loadPosts(user.id);
  console.log(`${user.name} has ${posts.length} posts`);
}

Compare that to the .then() chain from the last section: same work, but flat and linear, with the values landing in plain const bindings you can use on the next line. An async function always returns a promise itself, so calling showUser() gives you a promise you can await or .then() from somewhere else.

Errors are the part people miss. A rejected promise inside an async function throws, so you catch it with an ordinary try/catch:

js
async function showUser() {
  try {
    const user = await loadUser();
    console.log(`Loaded ${user.name}`);
  } catch (error) {
    console.log(`Failed: ${error.message}`);
  }
}

The same try/catch you would use for a synchronous error handles an async one, which is a large part of why async/await won.

async/await is syntax over promises. An async function always returns a promise, and await unwraps one: it suspends the function, yields the thread back to the runtime, and resumes with the resolved value when the promise settles. Nothing is blocked while it waits. The function is paused, but the single thread is free to run other work, which is the whole point.

The payoff is that async code regains the two things callbacks and raw chains cost you: linear reading order and normal control flow. await lets you use const, conditionals, and loops around async values as if they were synchronous:

js
async function publishReport(userId) {
  const user = await loadUser(userId);
  if (!user.active) {
    return null; // early return works normally
  }
  const posts = await loadPosts(user.id);
  return posts.filter((post) => post.published);
}

Error handling collapses into try/catch because a rejected awaited promise throws at the await:

js
async function publishReport(userId) {
  try {
    const user = await loadUser(userId);
    return await loadPosts(user.id);
  } catch (error) {
    console.log(`Report failed: ${error.message}`);
    return [];
  }
}

One subtlety worth internalizing: return await inside a try matters. If you write return loadPosts(...) without await, the function returns the promise and exits the try before the promise settles, so a rejection escapes the catch. With await, the rejection is thrown while you are still inside the try, and the catch sees it. That is the kind of detail that turns into a "why did my error handler not fire" bug, and the next section on the event loop and error patterns goes further into it.

Junoasync and await Mark a function async and you can await a promise inside it. await pauses that function until the promise resolves and hands you the value straight away, so the code reads top to bottom with no .then(). Wrap it in try and catch to handle failures, and the page keeps running while the function waits.
Junoasync and awaitasync/await is promises with cleaner syntax: await pauses the function until a promise resolves and gives you the value in a plain const. An async function always returns a promise, so you can await it from elsewhere. Errors throw, so a normal try/catch handles them, and that is most of why this style won.
Junoasync and awaitawait suspends the function and yields the thread, then resumes with the resolved value, so nothing blocks while you wait. You get linear reading, normal control flow, and try/catch for errors. Watch return await inside a try: drop the await and a rejection escapes before the catch can see it.

Fetching data

The most common async task you will write is loading data from a server. The browser gives you fetch for this. You pass it a URL, and it returns a promise for the response:

js
async function loadUsers() {
  const response = await fetch("https://api.example.com/users");
  const users = await response.json();
  console.log(users);
}

There are two awaits, and that surprises people at first. The first one waits for the server to respond. The second one reads the body of that response and turns it into JavaScript data with .json(), which is itself async. So fetching is two steps: get the response, then read it.

Servers can also respond with an error, like "not found". fetch does not treat that as a failure on its own, so you check response.ok yourself:

js
async function loadUsers() {
  const response = await fetch("https://api.example.com/users");
  if (!response.ok) {
    console.log("Request failed");
    return;
  }
  const users = await response.json();
  console.log(users);
}

Loading data from a server is the async task you will reach for most, and the browser's fetch is how you do it. Give it a URL and it returns a promise that resolves to a Response object:

js
async function loadUsers() {
  const response = await fetch("https://api.example.com/users");
  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`);
  }
  const users = await response.json();
  return users;
}

The two-step nature is the part to hold onto. The first await resolves when the response headers arrive, giving you a Response. That response is not the data yet, it is a handle to it. Reading the body is a second async step: response.json() returns a promise that resolves to the parsed data. Two awaits, two stages.

The gotcha that catches everyone: fetch only rejects on a network failure, not on an HTTP error status. A 404 or 500 still resolves; check response.ok yourself. response.ok is true for status codes in the 200s, so a guard on !response.ok is how you turn a bad status into a real error. Once you throw there, a try/catch around the call handles both network failures and bad responses in one place.

fetch(url) is the browser's async HTTP client, and it returns a promise that resolves to a Response. The design has one sharp edge worth stating up front: the promise rejects only on a network-level failure, a dropped connection or a blocked request. An HTTP error status like 404 or 500 is a successful round trip as far as fetch is concerned, so it resolves normally. Checking response.ok is not optional.

js
async function loadUsers() {
  const response = await fetch("https://api.example.com/users");
  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`);
  }
  return await response.json();
}

The two-stage shape reflects streaming. The first await settles when the response headers land, before the body has necessarily arrived. The Response is a handle to a stream, and response.json() reads that stream to completion and parses it, which is why it is itself async and needs its own await. Turning a bad status into a thrown error inside the function means one try/catch at the call site covers network failure, bad status, and JSON that fails to parse, three different failure modes routed to one handler:

js
try {
  const users = await loadUsers();
  render(users);
} catch (error) {
  console.log(`Could not load users: ${error.message}`);
}

That is the whole practical anatomy of a fetch: one promise for the response, a status check, a second promise for the body, and a single error boundary around the lot. The next section covers what happens when you need several of these at once.

JunoFetching datafetch(url) returns a promise for the server's response, and loading data is two steps: await fetch(...) for the response, then await response.json() to read the data out of it. A server error like "not found" does not fail on its own, so check response.ok before you read the body. Both steps use await because both take time.
JunoFetching datafetch returns a promise for a Response, and reading the body with response.json() is a second async step, so a normal fetch has two awaits. The trap: fetch only rejects on a network failure, so a 404 or 500 still resolves. Check response.ok and throw on a bad status, and one try/catch covers everything.
JunoFetching datafetch resolves when the headers arrive, not the body, which is why response.json() is a separate awaited step that reads the stream. It only rejects on a network failure, so a 404 resolves fine and you must check response.ok yourself. Throw on a bad status and one try/catch at the call site handles network errors, bad statuses, and parse failures together.

The event loop and running work in parallel

Everything so far has one theme: JavaScript starts slow work, keeps going, and comes back to it later. This section is the mechanism behind that, plus the patterns for running several async tasks well.

Here is a picture of how JavaScript keeps track of async work. Your running code sits on the call stack, the list of what is executing right now. When you call setTimeout, the timer is handed off to the browser to count down, so it is not on the stack blocking anything. When the timer finishes, its callback goes into a task queue, a waiting line of code ready to run.

The event loop is the part that connects them. It has one rule: run everything on the call stack first, and only when the stack is empty pull the next task from the queue. That is why setTimeout(fn, 0) still runs after your other code, even with a zero delay. The callback has to wait in the queue until the current code is done:

js
console.log("first");
setTimeout(() => console.log("third"), 0);
console.log("second");
// first
// second
// third

The delay of 0 does not mean "right now". It means "as soon as the current code finishes".

The event loop is the engine that makes all of this ordering work. Three parts: the call stack (the synchronous code running right now), the task queue (async callbacks waiting their turn), and the loop itself, which does one thing: when the call stack is empty, take the next task from the queue and run it.

That single rule explains the setTimeout(fn, 0) ordering. The delay is a minimum wait before the callback is queued, not a promise to run immediately, and a queued task cannot run until the current synchronous code finishes and clears the stack:

js
console.log("first");
setTimeout(() => console.log("third"), 0);
console.log("second");
// first
// second
// third

When you have several independent async tasks, running them one after another wastes time. Awaiting in a loop is the common mistake:

js
// Slow: each request waits for the previous one to finish
const users = [];
for (const id of ids) {
  users.push(await loadUser(id));
}

If the tasks do not depend on each other, start them together and wait for all of them at once with Promise.all. It takes an array of promises and returns a promise that resolves to an array of their results:

js
// Fast: all requests run at the same time
const users = await Promise.all(ids.map((id) => loadUser(id)));

Reach for Promise.all whenever you have independent async work, and keep the sequential await in a loop only when each step really needs the previous one's result.

The event loop is the scheduler that makes single-threaded async possible. The call stack holds the synchronous frames executing right now. Async callbacks do not push onto it directly; they wait in a queue. The loop's rule is simple: when the call stack is empty, dequeue the next task and run it to completion, then repeat. A task runs uninterrupted, which is why long synchronous work still blocks everything, async or not.

That model is the full explanation of setTimeout(fn, 0). The delay schedules the callback into the queue after a minimum time, but it cannot run until the stack drains, so it always follows the synchronous code:

js
console.log("first");
setTimeout(() => console.log("third"), 0);
console.log("second");
// first, second, third

One refinement to keep in your head: promise callbacks (the .then handlers and everything after an await) go on a separate, higher-priority microtask queue that drains completely between tasks, so a resolved promise's continuation runs before a queued setTimeout. You rarely need to reason about this, but it explains the occasional surprise where promise code beats a timer.

The practical payoff is scheduling your own work well. Independent async tasks should run in parallel, not in series. Awaiting inside a loop serializes them:

js
// Serial: total time is the sum of every request
for (const id of ids) {
  results.push(await loadUser(id));
}

// Parallel: total time is roughly the slowest single request
const results = await Promise.all(ids.map((id) => loadUser(id)));

Promise.all starts every promise immediately (the .map kicks them all off), then resolves once all settle, rejecting as soon as any one rejects. Use it for independent work; keep sequential await only when a step needs the previous result. One caution on race conditions: if two async operations write to the same state and you do not control their order, the last one to finish wins, which may not be the last one you started. When order matters, await the first before starting the second, or key each result so a late arrival cannot overwrite a newer one.

JunoThe event loop Your running code sits on the call stack, and when a timer finishes its callback waits in the task queue. The event loop runs everything on the stack first, then pulls the next waiting task, which is why setTimeout(fn, 0) still runs after the rest of your code. A delay of 0 means "as soon as the current code finishes", not "right now".
JunoThe event loop The event loop runs the call stack to empty, then takes the next task from the queue, which is exactly why setTimeout(fn, 0) waits for your synchronous code to finish. When async tasks do not depend on each other, do not await them one by one in a loop. Start them together and use Promise.all, and keep sequential await only when a step needs the last one's result.
JunoThe event loop The loop runs a task to completion, then the next, so long synchronous work blocks everything and setTimeout(fn, 0) always trails the current code. Promise continuations drain on a higher-priority microtask queue between tasks, which explains the odd time a .then beats a timer. Run independent work with Promise.all rather than awaiting in a loop, and when two operations touch the same state, control the order or a late finish overwrites a newer result.

Where async goes from here

Async is the part of JavaScript that keeps a page alive while slow work happens in the background. The shape is always the same: start something that takes time, let the rest of your code run, and handle the result when it arrives. Promises give that result a value you can pass around, async/await lets you write it in plain top-to-bottom code, and fetch is where you will use all of it most, loading data from a server.

From here, async connects to the parts of the language that react to the outside world. You will wire fetched data into the page through the DOM, and you will kick off async work in response to what people do on the page, which is events. The results you get back are almost always objects, so reading and reshaping them is the next skill that pairs with everything here.