Devpuff
Pricing
Log InStart Free
  1. Home
  2. Blog
  3. Async await in JavaScript, explained with measured timings
Learn

Async await in JavaScript, explained with measured timings

async and await let you write asynchronous code that reads top to bottom. Here is how they work, and the habit that made a real script 4.7 times slower.

By Max Arthur
Co-Founder & Content Marketer·August 25, 2026·11 Min read
Two timelines compared, one running five requests end to end in sequence and one running the same five overlapping in parallel

async and await are two keywords that let you write asynchronous JavaScript that reads from top to bottom. Mark a function async, and you may use await inside it. await pauses that function until a promise settles, then hands you the value instead of a promise.

async function getUser(id) {
  const response = await fetch(`/api/users/${id}`);
  const user = await response.json();
  return user;
}

That is the whole feature. No .then() chains, no nesting, no callback pyramid.

The part almost every tutorial skips is that await is a pause, and pauses add up. Written carelessly, the syntax that made your code readable also makes it several times slower, and nothing warns you. Every timing in this post came out of a real Node run, and the script is at the end so you can reproduce them.

What async actually does to a function

An async function always returns a promise. Even when you return a plain number:

async function plain() {
  return 42;
}

const result = plain();
console.log(typeof result); // object
console.log(result instanceof Promise); // true
console.log(await result); // 42
object
true
42

The 42 gets wrapped automatically. This is the single most useful thing to internalise, because it explains the most common beginner bug: calling an async function and getting Promise { <pending> } instead of your data. You did not get the wrong value. You got the box the value arrives in, and you forgot to open it.

await is the opener. It is also smart about nesting, so returning a promise from an async function does not give you a promise wrapped in a promise:

async function nested() {
  return wait(10, "inner value"); // returns a promise
}

console.log(await nested()); // inner value, not Promise { ... }

await pauses one function, not the program

This is the point people get backwards. "Blocking" sounds like the browser freezes. It does not. await suspends only the async function it appears in, and everything else keeps running.

async function inner() {
  console.log("2: inner start");
  await wait(50);
  console.log("4: inner after await");
}

console.log("1: before call");
inner();
console.log("3: after call, sync code kept running");
1: before call
2: inner start
3: after call, sync code kept running
4: inner after await

Line 3 printed before line 4. The inner function parked itself at the await and gave control back, so the rest of the script carried on. When the 50ms timer finished, inner picked up where it left off.

That is why a page stays responsive during a slow request. Buttons still click, animations still run. Only the one function is waiting.

The mistake that cost 4.7x

Here is the code almost everyone writes first. Five things to fetch, so loop and await each one:

const results = [];
for (const url of urls) {
  results.push(await wait(100, url)); // stands in for a real request
}

And here is the same work with Promise.all:

const results = await Promise.all(urls.map((url) => wait(100, url)));

Both produce ["a","b","c","d","e"], in that exact order. The measured difference:

Approach Time
await inside the loop 536 ms
Promise.all 114 ms

4.7 times slower, for five requests that each take 100ms. The loop version starts request 2 only after request 1 comes back. Promise.all starts all five immediately and waits for the slowest.

With twenty requests the gap is four times wider again. This is the single highest-value thing to know about await, and it does not show up in local testing because your fake data resolves instantly.

The rule: if the next await does not need the previous result, they should not be sequential.

// Sequential, and correctly so. The second call needs the first result.
const user = await getUser(id);
const orders = await getOrders(user.accountId);

// Sequential by accident. These do not depend on each other.
const user = await getUser(id);
const settings = await getSettings(id);

// Fixed.
const [user, settings] = await Promise.all([getUser(id), getSettings(id)]);

Note the ordering guarantee: Promise.all resolves to results in the order you passed them in, not the order they finished. You never have to sort them yourself.

When all at once is also wrong

Promise.all is the fix for the loop, and then people apply it to five hundred items and get rate-limited, or exhaust the connection pool, or watch a server return 429s. All at once is a real setting, and it has a real cost.

There are three shapes, not two. Measured on 20 jobs of 100ms each:

Shape Time Peak concurrent
Sequential (await in a loop) 2,041 ms 1
Limited pool (5 at a time) 433 ms 5
Promise.all 103 ms 20

The middle row is the one most real code should use, and it is not in the standard library. A small pool does it:

async function pool(items, limit, fn) {
  const results = [];
  const running = new Set();

  for (const item of items) {
    const p = fn(item).then((r) => {
      running.delete(p);
      return r;
    });
    running.add(p);
    results.push(p);
    if (running.size >= limit) await Promise.race(running);
  }
  return Promise.all(results);
}

It starts jobs until limit are in flight, then waits for any one to finish before starting the next. Results come back in input order, same as Promise.all, which the measurement confirmed.

Use sequential when each step needs the previous result, Promise.all when the list is small and bounded, and a pool when the list is long or the other end has limits. "Small and bounded" means you wrote the list yourself. A list whose length comes from user data is neither.

forEach silently ignores await

This one produces no error, no warning, and an empty result:

async function withForEach() {
  const out = [];
  ["x", "y", "z"].forEach(async (value) => {
    await wait(10);
    out.push(value);
  });
  return out;
}

console.log(await withForEach());
[]

Empty. forEach calls your async callback three times, each one returns a promise, and forEach throws all three away. The function returns out before a single push has happened.

Swap in for...of and it works:

async function withForOf() {
  const out = [];
  for (const value of ["x", "y", "z"]) {
    await wait(10);
    out.push(value);
  }
  return out;
}
["x","y","z"]

for...of respects await. So do for and while. Of the array methods, only map is genuinely useful here, and only because you feed its array of promises into Promise.all. If you are awaiting inside forEach, you have a bug.

Error handling: try/catch around the await

Because await unwraps a promise into a value, a rejected promise comes out as a thrown error. That means the ordinary try/catch you already know works:

async function boom() {
  throw new Error("network down");
}

try {
  await boom();
} catch (err) {
  console.log(err.message); // network down
}

Two things to get right.

The await must be inside the try. This is the classic miss:

// Broken: the promise is created inside try, but awaited outside it.
let data;
try {
  data = boom(); // no await, so nothing throws here
} catch (err) {
  // never runs
}
await data; // throws out here, uncaught

A promise you never await can still reject. Calling boom() without awaiting produces a rejected promise sitting on its own. In Node that ends the process with an unhandled rejection; in a browser it is a console error. Attach a .catch() if you deliberately want to fire something off and not wait for it.

Promise.all versus Promise.allSettled

Promise.all rejects the moment any one of its promises rejects, and you lose the results that did succeed:

const ok = Promise.resolve("ok");
const bad = Promise.reject(new Error("failed"));

try {
  await Promise.all([ok, bad]);
} catch (err) {
  console.log(err.message); // failed
}

The "ok" value is gone. For a page that loads five independent widgets, that is the wrong behaviour: one failing widget should not blank the other four.

Promise.allSettled never rejects. It waits for all of them and reports on each:

const settled = await Promise.allSettled([ok, bad]);
console.log(settled);
[
  { status: "fulfilled", value: "ok" },
  { status: "rejected", reason: Error: failed }
]

Pick by whether partial success is useful to you:

Method Rejects when Use it when
Promise.all Any one rejects You need every result, or none of them are useful
Promise.allSettled Never Independent work, and partial results still help
Promise.race First settles, either way Timeouts, or whichever source answers first
Promise.any Only if all reject Several mirrors of the same thing, first win takes it

Giving up on a request that never returns

await has no built-in timeout. If a request hangs, your function waits forever, and the spinner spins forever with it. Promise.race is the standard fix, because it settles as soon as the first of its promises settles:

function timeout(ms) {
  return new Promise((_, reject) =>
    setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms)
  );
}

try {
  const data = await Promise.race([slowRequest(), timeout(200)]);
} catch (err) {
  console.log(err.message);
}

Measured with a request that takes 500ms and a 200ms limit:

rejected: timed out after 200ms   (at 207ms)

And when the request is faster than the limit, the request wins and the timer is simply ignored:

resolved: quick   (at 55ms)

One honest caveat, and it is the reason this pattern is often taught badly: Promise.race does not cancel the loser. The slow request is still running, still using a connection, and will still resolve into nothing. Racing a timeout stops you waiting; it does not stop the work.

To actually cancel, you need AbortController, which fetch accepts directly:

const controller = new AbortController();
setTimeout(() => controller.abort(), 100);

await fetch(url, { signal: controller.signal }); // throws when aborted

Measured on an abortable operation with a 100ms limit: caught: aborted after 101ms, and the underlying timer was cleared rather than left running. Use Promise.race when you only need to stop waiting, and AbortController when the work itself should stop.

Where await is allowed

Inside any async function, and at the top level of an ES module. That last part is newer than a lot of tutorials, and it is why this works in a .mjs file or a <script type="module">:

const config = await fetch("/config.json").then((r) => r.json());

Inside a plain CommonJS script or a non-module <script> tag, top-level await is a syntax error. The fix is an async wrapper, or switching the file to a module.

Do promises still matter?

Yes, and this is worth being clear about, because async/await is often taught as a replacement. It is not. It is syntax over the same promise objects.

You still need promises directly whenever you are not awaiting one at a time: Promise.all, Promise.allSettled, Promise.race all take promises, and .catch() on a fire-and-forget call is often cleaner than a wrapper function. The honest summary is that await handles the sequential case beautifully and the promise API handles everything concurrent.

The checklist

Five things, in the order they bite:

  1. Getting Promise { <pending> }? You called an async function without awaiting it.
  2. Slow for no reason? Look for await inside a loop where the iterations do not depend on each other. Reach for Promise.all.
  3. Nothing happening in a forEach? forEach ignores async callbacks. Use for...of.
  4. catch not firing? The await has to be inside the try block, not just the call.
  5. One failure killing everything? Promise.all is all or nothing. Promise.allSettled is not.

Run it yourself

The reason the 536ms and 114ms numbers are in this post rather than a hand-waved "much faster" is that the gap is easy to reproduce and hard to believe until you do. Paste this into a .mjs file and run it with Node:

const wait = (ms, label) => new Promise((res) => setTimeout(() => res(label), ms));
const urls = ["a", "b", "c", "d", "e"];

let t0 = Date.now();
const sequential = [];
for (const u of urls) sequential.push(await wait(100, u));
console.log("sequential ms:", Date.now() - t0);

t0 = Date.now();
await Promise.all(urls.map((u) => wait(100, u)));
console.log("parallel ms:", Date.now() - t0);

Your numbers will differ by a few milliseconds. The ratio will not.

Async JavaScript walks through promises, await, error handling and the concurrency methods with the code running in your browser, so the timing difference is something you watch happen rather than read about.

Related reading: JavaScript Closures Explained, because the callback you pass to setTimeout is a closure and that is why it still sees your variables, useEffect Explained for fetching inside a React component without setting state on an unmounted one, and How Do Websites Work for what is actually happening during the wait.

Keep reading

More from the blog

Two arrays combined by spread into a new array, beside push spreading into an existing one and overflowing the stack
September 1, 2026·9 min readLearn

How to merge two arrays in JavaScript

To merge arrays in JavaScript, spread and concat both work. push(...arr) throws a RangeError at 200,000 items, which is why the choice matters.

Read more
The same object iterated with Object.keys and with for-in, where for-in returns an extra inherited key
August 31, 2026·9 min readLearn

How to loop through an object in JavaScript

To loop through an object in JavaScript, for...in walks inherited properties too. And integer-like keys come out first, whatever order you wrote.

Read more
{ }
✦

Ready to write some code?

Put this into practice - start your first free lesson. No setup, no credit card.

Start learning free
Devpuff

Learn to code by doing. One tiny, playful lesson at a time.

Learn
ProgramsCoursesPricing
Company
AboutBlogResourcesAffiliates
Support
Help CenterContactStatus
Programs
Frontend DevelopmentFull-Stack DevelopmentPython Developer
Courses
Advanced ReactAsync JavaScriptAsync PythonCSS BasicsCSS LayoutDSA Basics
Learn to Code
Learn JavaScriptLearn PythonLearn SQLLearn HTMLLearn CSSLearn ReactBrowse All Topics
Platform Comparisons
Devpuff vs CodecademyDevpuff vs MimoDevpuff vs Sololearn
© 2026 Devpuff. All rights reserved.Privacy PolicyTerms and ConditionsCookies PolicyRefund Policy
Devpuff vs freeCodeCamp
Read All Comparisons