Skip to content
Software Atlas

Async / Await

Why waiting is the hardest problem in software, how JavaScript's event loop and Go's scheduler solve it, and the production patterns that keep async systems fast and correct.

⏱ ~45 min read16 sections13 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

Almost every program spends most of its life waiting — for the network, the disk, a database, a user. Async/await is a way to write code that waits without wasting.

In one sentenceawait pauses the function, not the thread. While your function waits, the same thread is free to run other code.

First, the words. Synchronous code runs right now, line by line: the next line waits until this one is done. Asynchronous (async) work starts now but finishes later, and your code is told when it is ready — await is how you say "continue here once it is ready".

A Thread is one worker that runs your code line by line. "Other code" means everything else your program has to do meanwhile: another user's request on a server, a button click in a browser, a timer that fired. (The next section, §02, explains threads properly.)

1

Waiting is the enemy

A CPU can execute millions of instructions in the time one network call takes. A thread stuck waiting is a thread doing nothing.
2

A Promise is a receipt

It stands for a value that doesn't exist yet. You can pass it around and await it when you need the value.
3

Two philosophies

JavaScript: one thread, and you mark the pauses with await. Go: thousands of cheap goroutines; code simply looks like it waits.

Here is the most practical consequence. Three requests take 300, 500 and 400 ms. Try both ways of awaiting them:

Same three requests, two strategies
Watch the main thread lane — it only works in tiny white slivers.
GET /user
GET /orders
GET /recs
main thread
idle — free to run other work
0ms200ms400ms600ms800ms1000ms1200ms
t = 0 ms
Pick a strategy and press Run.
the code behind the widget
// get(url) returns a Promise — a receipt for the response.

// One by one: total ≈ 300 + 500 + 400 = 1200 ms
async function sequential() {
  const user   = await get("/user");    // wait…
  const orders = await get("/orders");  // …then start the next, wait…
  const recs   = await get("/recs");    // …then the last
  return { user, orders, recs };
}

// Start all three, THEN wait: total ≈ max(300, 500, 400) = 500 ms
async function concurrent() {
  const [user, orders, recs] = await Promise.all([   // ← all three start here
    get("/user"), get("/orders"), get("/recs"),
  ]);
  return { user, orders, recs };
}
Foundations§02

Primer: the words you need (and Go in 90 seconds)

The rest of this page uses a handful of words and a little Go. Here they are, in plain language. If you already know them, skip to §03.

Four computer words

T

Thread

One worker that runs code line by line. A program can have several. The operating system decides which thread runs on which CPU core, and switches between them.
OS

Operating system (OS)

Linux, macOS, Windows. It owns the hardware: memory, threads, files and network connections. Each running program is a process with its own memory and threads; programs ask the OS for things through system calls ("read this file").
IO

I/O

Input/output: anything outside the CPU and memory — network requests, disk, databases. I/O is slow compared to the CPU (§03 shows how slow).

The fourth word is about where a function's data lives. While a function runs, its local variables sit on the Call stack. When it returns, they're thrown away. Data that must survive longer goes on the Heap.

Where a function's data lives
Call stack — a pile of plates
main() — locals: config
handleRequest() — locals: req, res
getUser(id) — locals: id, row
Each call adds a plate on top; returning removes it and its local variables vanish.
Heap — a shelf
user objectcache Mapa paused function's variablesbig array
Things on the shelf stay until nothing points to them anymore; then the garbage collector clears them.

This matters later: a paused async function can't keep its variables on the stack (the stack moves on to other work), so they're saved on the heap instead (§08).

Go in 90 seconds

You don't need Go to follow the JavaScript parts. But every Go snippet on this page uses only the pieces below.

// A goroutine is a function running alongside the rest of the program:
// starting it doesn't wait for it to finish.
go sendEmail(user)      // starts it, and continues immediately — doesn't wait
fmt.Println("sent?")    // runs right away, probably before the email is sent

// ⚠️ When main() returns, the whole program exits —
//    including any goroutines that haven't finished.

Here's the WaitGroup tab as a picture — compare it with the §01 timeline:

The WaitGroup version, on a timeline
main
Add(3)
Wait() — blocked until counter = 0
continues
/user
300 ms
Done 2
/orders
500 ms
Done 0
/recs
400 ms
Done 1
The counter starts at 3. Each goroutine calls Done() when it finishes (3 → 2 → 1 → 0). main waits in Wait() until it hits 0 — at ~500 ms, the slowest request, exactly like the “start all, then await” strategy in §01.

Channels are the least familiar piece, so here's one to play with. Every button starts a new goroutine that does a single send or receive:

Channel playground
Each button starts a new goroutine that does one thing. Watch who blocks and who wakes up.
ch := make(chan int)
Blocked sendersch <- v
none
The channelcapacity 0
No storage at all. A sender and a receiver must meet — whoever arrives first waits.
Blocked receiversv := <-ch
none
received: —
Try it: with unbuffered, press “go send” first — the sender blocks until a receiver shows up.
Foundations§03

Why async exists: the cost of waiting

To feel why async exists you need a sense of scale. Here are approximate times for common operations — and the same numbers if one nanosecond were stretched to one second. (1 ns = a billionth of a second; 1 µs = 1,000 ns; 1 ms = 1,000,000 ns. The bars use a log scale: each step to the right is about 10× slower, otherwise the short ones would be invisible.)

Latency, at human scale
Log-scale bars. Right column: “if 1 ns were 1 second”. Numbers are approximate and vary by hardware.
OperationActualHuman scale
L1 cache reference1 ns1 sec
Main memory (RAM) reference100 ns1.7 min
Random 4 KB read from SSD16 µs4.4 hours
Round trip within a datacenter500 µs5.8 days
Disk seek (spinning HDD)4 ms46 days
Packet US → Europe → US150 ms4.8 years

L1 cache = a tiny memory inside the CPU itself. RAM = the computer's main memory. SSD / HDD = the disk (flash / spinning). Round trip = sending a message to another machine and getting the reply.

A network round trip inside a datacenter is ~500,000× slower than reading from the CPU's own cache. A barista who stands still during that wait is standing still for six days, in human terms.

Model 1 — a thread per connection

The classic server gives each connection (each user) its own Thread. The code is simple: when a thread waits for the database, it just… waits. But threads aren't free:

  • Memory — every thread needs its own scratch memory for its call stack, commonly reserved at 1–8 MB each.
  • Switching — when thousands of threads take turns on a few CPU cores, the OS spends real time just switching between them (a Context switch).
  • Scale — 10,000 users meant 10,000 threads, almost all of them asleep, waiting. This was famous enough to get a name: the C10K problem (10K = 10,000 connections).

Model 2 — one thread and a doorbell

The async model flips it. Keep one thread (or a few), and hand the operating system a list of every open connection (each one a Socket) with an instruction: "wake me up when any of these has data." That feature is called epoll / kqueue / IOCP on Linux (kqueue on macOS, IOCP on Windows).

The thread then only works when there's actually something to do — like a barista who only moves when a buzzer goes off:

One thread, many connections
The OS watches every connection and rings a “doorbell” for each one that has data. The thread only works when a bell rings.
Open connections (sockets)
💤 quiet
user 1
💤 quiet
user 2
💤 quiet
user 3
💤 quiet
user 4
💤 quiet
user 5
💤 quiet
user 6
OS (epoll)
ready list:
—
→
The one thread
Asleep until a bell rings
handled so far: 0
Thread-per-connection for 10,000 users: 10,000 threads, each reserving its own stack memory — mostly asleep. This model: 1 thread plus a list kept by the OS.

The same idea as a timeline:

Thread per connection
thread 1
waiting on socket…
thread 2
waiting…
thread 3
waiting…
thread 4
waiting…
thread 5
waiting…
5 stacks, 5 kernel threads — and ~90% of their lifetime is spent asleep.
Event loop (one thread)
main
kernel
epoll / kqueue watches all 5 sockets
1 stack. The kernel watches the sockets; the thread only runs the short bursts of real work.
Foundations§04

Concurrency ≠ parallelism

Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.— Rob Pike, co-creator of Go

In kitchen terms: one cook juggling three pans is concurrency — all three dishes are in progress, but only one gets stirred at any instant. Three cooks, one pan each is parallelism — three things literally happen at the same moment.

Concurrency is about how you organize work (several tasks in progress, taking turns). Parallelism is about hardware (several CPU cores running at the same instant). You can have either without the other:

Three tasks — A, B, C — over time
1 core
A
B
A
C
B
A
C
B
⬆ Concurrent, not parallel — JavaScript's world. One thread interleaves tasks at await points.
core 1
A
A
A
core 2
B
B
B
⬆ Parallel — each task gets its own core.
core 1
A
C
A
B
C
A
core 2
B
A
C
B
B
⬆ Both — Go's world. Goroutines interleave and spread across GOMAXPROCS cores (default: all of them).
JavaScript (browser / Node)Go
Your code runs onOne main thread per page or Node processUp to GOMAXPROCS threads at once (default: one per CPU core)
Unit of concurrencyPromise / async functionGoroutine
ParallelismOnly if you create worker threadsBuilt in: goroutines spread over all cores
Can two pieces of your code touch the same variable at the same instant?No (one thread runs your JS)Yes — a Data race, prevented with locks or channels (§13)
Can a variable change while your function is paused?Yes! (§07 shows how)Yes
Foundations§05

From callbacks to promises to await

JavaScript reached async/await in three steps. Here is the same program — fetch a user, then their orders, then an invoice — written in each style. Click through the tabs:

// Node convention: the callback's first argument is the error (or null).
getUser(id, (err, user) => {
  if (err) return handle(err);
  getOrders(user.id, (err, orders) => {
    if (err) return handle(err);
    getInvoice(orders[0].id, (err, invoice) => {
      if (err) return handle(err);
      render(user, orders, invoice); // 🔺 the "pyramid of doom"
    });
  });
});

What was actually wrong with callbacks?

  • You hand over control. A callback is "the rest of your program", passed to someone else's function to call later. That's called a Continuation. You have to trust that code to call it exactly once — not twice, not never.
  • Normal language features stop working. return, throw, try/catch and for loops don't work across callbacks. For example:
try {
  getUser(id, (err, user) => {
    throw new Error("boom");   // runs 300 ms later…
  });
} catch (e) {
  console.log("caught it");    // …never runs: the try finished long ago
}
Why try/catch doesn't work with callbacks
try { getUser(id, callback) } catch { … }
try block
try {…}
300 ms later
callback throws!
nobody catches → crash
try { await getUser(id) } catch { … }
try block
try {
paused at await — still inside try
catch ✓
300 ms later
throw
With a callback, the try block has already finished by the time the error happens — there's nobody left to catch it. With await, the function pauses inside the try, so the error lands in the catch.
  • Error handling is manual and repeated at every level.

A Promise (next section) fixed the trust problem: it's an object that finishes exactly once, and it always calls your code later (never sometimes-immediately), with either a value or an error. Then async/await fixed readability: it's a shorter way to write promise code that gives back return, throw, try/catch and loops.

Foundations§06

Promises: a state machine for a future value

In one sentenceA Promise starts pending, then settles exactly once — either fulfilled with a value or rejected with an error — and never changes again.

Press the buttons. resolve(42) means "the work succeeded, here's the value"; reject(err) means "it failed":

A promise's life
Try resolving it twice. Try rejecting after resolving.
pendingwaiting…fulfilledvaluerejectedreasonsettled = immutable
const p = new Promise((resolve, reject) => …)
// state: pending

The rules that matter

  • The setup function runs immediately. In new Promise(fn), fn (called the executor) runs right away, before the next line of your code. The Promise hands your executor two functions, usually named resolve and reject: call resolve(value) when the work succeeds, or reject(error) when it fails.
  • Settles once. Extra resolve() / reject() calls are silently ignored. That single guarantee is what killed "my callback ran twice" bugs.
  • Handlers always run a little later. Your .then callback never runs immediately, even if the promise is already settled. It's queued as a Microtask — a small job JavaScript runs as soon as the current code finishes. (§07 shows exactly when.)
  • .then returns a new promise, which is why you can chain .then().then(). If your callback returns a promise, the chain waits for it — you never end up with a "promise of a promise".
  • Promises are eager. When you hold a promise, the work has already started. It's a receipt for an order already placed, not a recipe you can run later.

The same idea in Go: a channel you receive from once

Go has no Promise type, because you rarely need one: you just call the function and the goroutine waits (cheaply — see §02). When you do want "start now, get the result later", the idiom is a goroutine plus a channel with one slot:

a future, two ways
function fetchAsync(url) {
  return new Promise((resolve, reject) => {
    // this executor runs NOW
    httpGet(url, (err, body) => (err ? reject(err) : resolve(body)));
  });
}

const pending = fetchAsync("https://api.example.com/user"); // work starts here
doOtherThings();
const body = await pending;                                  // collect the result
// (await at the top level works in ES modules and the browser console;
//  elsewhere, put this inside an async function)
Practitioner§07

The event loop, step by step

JavaScript runs your code on one thread. The waiting itself (timers, network, disk) is done by the Host — the browser, or Node.js. When something finishes, the host puts a note in a queue. The Event loop is the loop that takes notes out of the queues and runs the matching code, one at a time.

There are two queues, and the difference between them explains every "why did this print first?" puzzle:

S

Call stack

The function(s) running right now. JS runs each piece of code to completion — nothing can interrupt it halfway.
μ

Microtask queue

Small follow-up jobs: promise .then callbacks and the code after an await. Emptied completely after every task.
T

Task queue

Bigger jobs: timers, clicks, network callbacks, and the script itself. One per loop turn. People often call these macrotasks to contrast with microtasks.
event-loop.pseudo.js
// The event loop, conceptually (heavily simplified)
while (true) {
  const task = taskQueue.shift();   // 1. take ONE task: a timer, a click… or the script
  if (task) run(task);               //    run it to the end; nothing interrupts it

  while (microtaskQueue.length) {    // 2. then run ALL microtasks — including any
    run(microtaskQueue.shift());     //    new ones added while doing this
  }

  if (isBrowser && frameDue()) render(); // 3. browsers repaint the screen here
}

Two facts you'll need for the code below. Promise.resolve() creates a promise that is already fulfilled. And calling an async function runs its body immediately, line by line, up to its first await — only the part after the await is deferred.

Now watch it run. Press Play, or step with ← →. Before you start: guess the output order.

Event loop visualizer
Guess the output order first. Then step through.
Code
1console.log("A");
2setTimeout(() => console.log("B"), 0);
3Promise.resolve().then(() => console.log("C"));
4
5async function run() {
6 console.log("D");
7 await null;
8 console.log("E");
9}
10
11run();
12console.log("F");
Call stackruns now
script (main)
Host APIstimers · I/O
nothing pending
Microtask queuedrained fully
empty
Macrotask queueone per turn
empty
Console
(no output yet)
The whole script is itself the first macrotask. It's pushed on the call stack and will run to completion.
1 / 15

"No interruptions" has a catch: races across await

Because only one piece of JS runs at a time, two lines of normal code can never be interleaved. But every await is a pause where other code runs. So something you checked before an await may no longer be true after it. Step through this bank-account example:

The withdraw race, step by step
One thread, no parallelism — and still a race. Step through it.
Code
1let balance = 100;
2
3async function withdraw(n) {
4 if (balance >= n) { // check
5 await audit(n); // ⏸ pause
6 balance -= n; // act (stale!)
7 }
8}
9
10withdraw(80); // call A
11withdraw(80); // call B
call A
not started
call B
not started
balance
100
Two withdrawals of 80 start, one right after the other. There is only 100, so exactly one should succeed. (audit(n) stands for any slow async call — say, writing an audit log to a database.)
1 / 7

Microtask starvation

Because the microtask queue is emptied completely — including microtasks added along the way — a microtask that keeps scheduling another one never lets the loop move on:

function spin() {
  Promise.resolve().then(spin); // each microtask queues another one, forever
}
spin();
// Timers never fire, clicks are never handled, the page never repaints. Frozen.

Node.js details (skip on a first read)

Node's loop runs through a fixed cycle of phases, each with its own queue: timers (setTimeout) → I/O callbacks → setImmediate → close callbacks. Two Node-only extras:

APIRuns…
process.nextTick(fn)Even before promise microtasks — use sparingly
Promise.then / await / queueMicrotaskRight after the current task, before any I/O or timer
setTimeout(fn, 0)Next loop turn at the earliest (browsers wait ≥ 4 ms for nested timers)
setImmediate(fn)Right after the current batch of I/O callbacks
Staff-level§08

What await actually compiles to

There is no magic thread pausing. An async function is rewritten into a state machine: the function body is cut at every await, and each piece becomes a callback that runs when the awaited promise settles. A state number remembers which piece runs next.

Remember the plates and the shelf from §02? The call stack is cleared while the function is paused (the thread is busy with other work), so the function's local variables are kept on the Heap instead — that's what lets them survive the pause.

desugaring
async function getUserName(id) {
  const res  = await fetch(`/users/${id}`);
  const user = await res.json();
  return user.name;
}

Step through it — watch the state number and the saved variables on the heap:

Inside an async function
Watch the hidden state object while the function pauses and resumes.
Code
1async function getUserName(id) {
2 const res = await fetch("/users/" + id);
3 const user = await res.json();
4 return user.name;
5}
6
7getUserName(42).then(console.log);
Call stack (the thread)
getUserName(42)
Heap: saved state
state = 0
Waiting on
nothing
Calling the function creates a small object on the heap to hold its variables and a state number (which piece runs next). Piece 0 starts running right now.
1 / 7

Here's the same thing on the timeline of a single thread. Each colored block is one "piece" of getUserName; between them, the thread runs unrelated work:

One thread, one async function cut into 3 pieces
main
① fetch()
other
other
② .json()
other
③ return
network
HTTP in flight
body stream
The function's logical lifetime spans the whole bar; its CPU lifetime is just the three violet slices.
Practitioner§09

Go: no async/await — on purpose

The Go answerIn Go, waiting is cheap. When a goroutine waits on the network, the Go runtime sets it aside and runs another goroutine on the same thread. Go does what await does for you — everywhere, invisibly.

Look at a Go HTTP handler. It reads like plain step-by-step code with no await anywhere, yet one server process happily handles tens of thousands of users at once:

handler.go
func handleProfile(w http.ResponseWriter, r *http.Request) { // *T = a pointer to a T
    ctx := r.Context()                         // cancelled if the user disconnects
    user, err := db.GetUser(ctx, userID(r))    // waits — but only this goroutine
    if err != nil {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }
    orders, err := ordersAPI.List(ctx, user.ID) // waits again — still cheap
    if err != nil {
        http.Error(w, "upstream error", http.StatusBadGateway)
        return
    }
    json.NewEncoder(w).Encode(Profile{user, orders})
}
// Go's HTTP server runs every request in its own goroutine automatically.

Function coloring: the problem Go side-steps

In JS, async is contagious. To await something, your function must be async — so its callers get a Promise back, so they must await it, so they must be async too… all the way up. People describe this as functions having two "colors": red = async, blue = normal, and a blue function can't easily wait for a red one. Press the button:

Function coloring, animated
Make one leaf function async and watch what happens to everything above it.
JavaScript
main()
handleRequest()
renderPage()
loadUser()
readConfig()
Red = async. Every caller of a red function must turn red too, up to the top.
Go
main()
handleRequest()
renderPage()
loadUser()
readConfig()
Nothing changes color. If a caller wants concurrency, it writes go readConfig() at the call site.

In Go every function is the same color. A function that does I/O is just a function. Concurrency is decided by the caller, by writing go in front of a call — not by how the function is declared. (Fair note: if a Go caller wants a goroutine's result, it needs a channel or a WaitGroup — that's Go's version of the ceremony, but it only appears where you actually want concurrency.)

Under the hood: G, M and P

The Go runtime has its own scheduler with three kinds of things. An office makes a good picture:

  • G — goroutine = a task card: what to do, plus its own little call stack.
  • M — machine = a worker: a real OS thread.
  • P — processor = a desk with an inbox of task cards. There are exactly GOMAXPROCS desks (default: one per CPU core). A worker needs a desk to work — that's how Go limits how many goroutines run at the same instant.

When a task has to wait for the network, its card goes to the Netpoller (Go's use of epoll from §03) and the worker picks the next card from the inbox. Step through it:

Go scheduler: G · M · P
GOMAXPROCS = 2. Step through with ← →.
Desk P0 · running
G1 on M0
P0 · inbox
G2
G3
Desk P1 · running
G4 on M1
P1 · inbox
G5
Netpollerepoll · kqueue
no waiting sockets
Global run queue
empty
Stuck in OSsystem call
none
GOMAXPROCS=2 → two desks (P), each with an inbox of task cards (its run queue) and a worker thread (M) sitting at it. G1–G5 are goroutines: the task cards.
1 / 10
Practitioner§10

Rosetta stone: JS ↔ Go

Bookmark this. Every JS async concept has a Go counterpart — usually a smaller, more explicit one. Most Go pieces here were introduced in §02; errgroup and the combinators are covered in §11, panic in §12. Skim this table now and come back to it later.

JavaScriptGoNotes
async function f()func f()No special color. Run it concurrently with go f().
await pJust call the function / v := <-chBlocking a goroutine is cheap.
Promise<T><-chan T or (T, error)A buffered channel of 1 is Go's "future".
Promise.allerrgroup / sync.WaitGrouperrgroup.WithContext also cancels siblings on first error — better than JS!
Promise.allSettled (wait for all, keep every result)WaitGroup + a slice of per-item errorsEach goroutine writes into its own slot of a shared slice.
Promise.race (first to finish wins)select over several channels, or the first <-ch from one shared results channelFirst one ready wins.
Promise.any (first success wins)Loop receiving from a results channel, skipping errorsStop when one succeeds or all fail.
AbortController / AbortSignalcontext.Context + cancel()Go passes ctx as the first parameter by convention.
AbortSignal.timeout(ms)context.WithTimeout(ctx, d)Always defer cancel().
setTimeout / sleeptime.After, time.NewTimerUse inside select alongside ctx.Done().
try { await … } catchif err != nil { … }Errors are values in Go.
Unhandled rejectionPanic in a goroutineBoth crash a server Process (Node ≥ 15; browsers just log it) — in Go, unless the panic is recovered inside that same goroutine (§12).
AsyncLocalStorage (Node)context.Context valuesData that belongs to one request — e.g. its ID, the logged-in user.
Event loop + libuvRuntime scheduler + netpollerBoth sit on epoll / kqueue / IOCP.
worker_threads…just goroutinesGo is parallel by default.
Practitioner§11

Production patterns

1 · Kill the waterfall

The most common async performance bug is the one from §01: awaiting independent operations one after another (a "waterfall"). If B doesn't need A's result, start both before waiting for either.

parallel fetch
// ❌ Waterfall: 3 × ~200 ms = ~600 ms
const user   = await getUser(id);
const feed   = await getFeed(id);
const promos = await getPromos(id);

// ✅ Independent → start all, then await: ~200 ms
const [user, feed, promos] = await Promise.all([
  getUser(id), getFeed(id), getPromos(id),
]);

2 · Pick the right combinator

Four ways to wait on several promises. They differ in when they finish and what they do when one fails. Play with it:

Combinator playground
Set each promise's duration and outcome, pick a combinator, run.
A
300ms
B
600ms
C
450ms
Results appear here.
Go equivalent of Promise.all
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return a(ctx) })
g.Go(func() error { return b(ctx) })
g.Go(func() error { return c(ctx) })
err := g.Wait() // first error; ctx cancelled → siblings can stop early
CombinatorFinishes when…Use it for
Promise.allAll succeed, or the first one fails (fail-fast)Everything is required
Promise.allSettledAll are done, success or failure — it never fails itselfBest-effort batches; reporting each item's result
Promise.raceThe first one finishes, success or failureTimeouts (race the work against a timer)
Promise.anyThe first one succeeds; fails only if all fail (with an AggregateError)Asking several servers and taking the fastest answer (a hedged request)

3 · Limit how many run at once

(errgroup appears below — it's a WaitGroup that also returns the first error.)

Promise.all(ids.map(fetchUser)) over 10,000 ids starts 10,000 requests at the same moment. The other side rate-limits you, or your own database runs out of connections. The fix is a small worker pool: a few workers, each taking the next item from a shared list as soon as it's free. Compare:

Bounded vs unbounded fan-out
12 URLs to fetch. Each worker grabs the next one (that's next++) as soon as it's free.
url1
url2
url3
url4
url5
url6
url7
url8
url9
url10
url11
url12
next = 0in flight: 0database connections: 0 / 5
With a limit, at most 3 requests are in flight; the rest wait their turn in the queue.
bounded fan-out
// Run fn over every item, with at most `limit` running at once.
// Results come back in the same order as items.
async function mapLimit(items, limit, fn) {
  const results = new Array(items.length);
  let next = 0;                      // index of the next item to grab

  async function worker() {
    while (next < items.length) {
      const i = next++;              // grab an index (safe: no await in between)
      results[i] = await fn(items[i], i);
    }
  }

  // Start `limit` workers; they all share `next`. Wait for every worker to finish.
  const workers = [];
  for (let w = 0; w < Math.min(limit, items.length); w++) workers.push(worker());
  await Promise.all(workers);
  return results;
}

const pages = await mapLimit(urls, 8, (url) => fetch(url).then((r) => r.text()));

4 · Timeouts and cancellation

Every network call should have a deadline: a point after which you give up instead of waiting forever.

deadline-aware I/O
async function getJSON(url, options = {}) {
  const signal = options.signal;                             // optional: caller's cancel signal
  const timeoutMs = options.timeoutMs ?? 2000;               // ?? = use 2000 if not given
  const timeout = AbortSignal.timeout(timeoutMs);            // aborts after N ms
  const combined = signal
    ? AbortSignal.any([signal, timeout])                     // abort on EITHER one
    : timeout;

  const res = await fetch(url, { signal: combined });        // throws if aborted
  if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); // ok = status 200–299
  return res.json();
}

// Cancel when the user closes the report panel:
const ctrl = new AbortController();
closeButton.onclick = () => ctrl.abort();
try {
  const report = await getJSON("/api/report", { signal: ctrl.signal });
  show(report);
} catch (err) {
  if (err.name !== "AbortError") throw err; // aborting on purpose isn't an error
}

5 · Retry with exponential backoff and jitter

Networks fail briefly all the time, so retrying is normal. But how you retry matters. Exponential backoff means waiting longer each time (1 s, 2 s, 4 s…). Jitter means adding randomness to those waits. Here's why the randomness matters:

60 clients retry after an outage
Each bar = retries arriving in half a second. Red = more than the server can handle.
server capacity
0 s8 s16 s
Without jitter, all 60 clients retry at exactly 1 s, 3 s, 7 s, 15 s. Each spike overloads the server again: a thundering herd.
retry
// Sleep for `ms`, but wake up early (with an error) if `signal` aborts.
const sleep = (ms, signal) =>
  new Promise((resolve, reject) => {
    const t = setTimeout(resolve, ms);
    signal?.addEventListener("abort", () => {   // ?. = only if a signal was given
      clearTimeout(t);
      reject(signal.reason);
    }, { once: true });                          // run this listener at most once
  });

// Retryable = temporary failures: timeouts, HTTP 429 (too many requests),
// 502/503/504 (server temporarily down or overloaded).
// NOT retryable: 400 Bad Request, 401, 404 — trying again won't change the answer.
async function retry(fn, { attempts = 5, baseMs = 100, maxMs = 5000, signal } = {}) {
  for (let i = 0; ; i++) {
    try {
      return await fn();                // `return await` so the catch sees failures (§13, pitfall #3)
    } catch (err) {
      if (i + 1 >= attempts || !isRetryable(err)) throw err;
      const cap = Math.min(maxMs, baseMs * 2 ** i);  // 100, 200, 400, 800… (2 ** i = 2ⁱ)
      await sleep(Math.random() * cap, signal);       // full jitter: random 0…cap
    }
  }
}
Practitioner§12

Errors across async boundaries

In JS, a rejected promise becomes a thrown error at the await — so a normal try/catch around the await catches it. That only works if someone actually awaits. If nobody does, you get an Unhandled rejection. In Go, errors are plain return values, and a goroutine can't return anything to its caller — you must send errors back yourself, through a channel or an errgroup.

errors
async function save(doc) {
  db.insert(doc);          // ❌ "floating" promise: nobody awaits it.
                           //    If it fails, nobody catches it → Node ≥15 crashes.
  await db.insert(doc);    // ✅ errors surface here, inside save()
}

// Fire-and-forget is fine — if you handle the error explicitly.
// `void` tells readers (and linters): "not awaiting this on purpose".
void sendAnalytics(evt).catch((err) => log.warn("analytics failed", err));

// Last line of defense (log + exit; don't try to keep running):
process.on("unhandledRejection", (reason) => {
  log.fatal({ reason }, "unhandled rejection");
  process.exit(1);
});
Practitioner§13

Pitfalls gallery

Seven bugs I've seen in real code reviews, each with the fix.

#1 · JS

await inside forEach

forEach ignores the promises your callback returns. "done" logs before anything is saved, and rejections go unhandled.

✕ Don't
items.forEach(async (item) => {
  await save(item);
});
console.log("done"); // runs first!
✓ Do
// sequential, in order:
for (const item of items) await save(item);

// or concurrent:
await Promise.all(items.map(save));
#2 · JS

Blocking the event loop with CPU work

async doesn't move work off the thread. 200 ms of heavy calculation = 200 ms during which no other request is served, no timer fires, nothing renders. The fix is to run the calculation on a Worker thread. A worker pool library like Piscina keeps a few worker threads ready and hands each job to a free one.

✕ Don't
app.get("/report", async (req, res) => {
  const rows = await loadRows();
  res.json(crunch(rows)); // sync & CPU-heavy: the whole server stalls
});
✓ Do
import Piscina from "piscina"; // a pool of worker threads
// crunch.js runs in the workers: `export default (rows) => crunch(rows);`
// new URL(…, import.meta.url) = "the crunch.js file next to this one"
const pool = new Piscina({ filename: new URL("./crunch.js", import.meta.url).href });

app.get("/report", async (req, res) => {
  const rows = await loadRows();
  res.json(await pool.run(rows)); // CPU work on another thread
});
#3 · JS

return vs return await inside try

return fetchData() leaves the try block immediately, handing the still-pending promise to the caller. When it fails a moment later, the code is no longer inside the try — so your catch never sees it. return await waits for the result while still inside the try.

✕ Don't
async function load() {
  try {
    return fetchData();       // rejection escapes the try
  } catch {
    return FALLBACK;          // never runs
  }
}
✓ Do
async function load() {
  try {
    return await fetchData(); // settles inside the try
  } catch {
    return FALLBACK;          // ✅ works
  }
}
#4 · Go

The goroutine leak

The goroutine sends its result on an unbuffered channel, so the send waits for a receiver. On timeout, the function returns — and now nobody will ever receive. The goroutine waits forever, and the garbage collector can't free it (it's still "running"). Do this once per request, and memory grows until the server is OOM-killed.

See it for yourself: pick unbuffered, press go send() three times and never receive. Those are three goroutines stuck forever. Now switch to buffer 1: a single send succeeds without anyone receiving.

Channel playground
Each button starts a new goroutine that does one thing. Watch who blocks and who wakes up.
ch := make(chan int)
Blocked sendersch <- v
none
The channelcapacity 0
No storage at all. A sender and a receiver must meet — whoever arrives first waits.
Blocked receiversv := <-ch
none
received: —
Try it: with unbuffered, press “go send” first — the sender blocks until a receiver shows up.
✕ Don't
func get(ctx context.Context) (int, error) {
    ch := make(chan int) // unbuffered
    go func() { ch <- slowCall() }() // blocks forever after a timeout
    select {
    case v := <-ch:
        return v, nil
    case <-ctx.Done():
        return 0, ctx.Err() // goroutine leaked
    }
}
✓ Do
func get(ctx context.Context) (int, error) {
    ch := make(chan int, 1) // buffer of 1: the send always completes
    go func() { ch <- slowCall(ctx) }() // and pass ctx so it can stop early
    select {
    case v := <-ch:
        return v, nil
    case <-ctx.Done():
        return 0, ctx.Err() // goroutine finishes, then is GC'd
    }
}
#5 · Go

Data races on shared state

Go runs goroutines truly in parallel, so two goroutines writing the same variable is a real Data race. For maps, Go crashes on purpose with fatal error: concurrent map writes; for other types the data just silently becomes wrong. Protect shared data with a mutex, and run tests with go test -race — it detects races for you.

✕ Don't
counts := map[string]int{}
for _, w := range words {
    go func() { counts[w]++ }() // race!
}
// …and nobody waits for the goroutines
✓ Do
var (
    mu sync.Mutex
    wg sync.WaitGroup
)
counts := map[string]int{}
for _, w := range words {
    wg.Add(1)
    go func() {
        defer wg.Done()
        mu.Lock()
        counts[w]++
        mu.Unlock()
    }()
}
wg.Wait()
#6 · JS

Async work in a constructor

Constructors can't be async, so people stash a promise on this and hope every caller remembers to await it. Use an async factory instead — a static method (called on the class itself, Repo.create(…)) that does the async setup and then builds the object. An object that exists is an object that's ready.

✕ Don't
class Repo {
  constructor(url) {
    this.ready = connect(url).then((c) => (this.conn = c));
  }
  query(sql) { return this.conn.query(sql); } // 💥 if not ready yet
}
✓ Do
class Repo {
  static async create(url) {
    return new Repo(await connect(url));
  }
  constructor(conn) { this.conn = conn; }
  query(sql) { return this.conn.query(sql); }
}
const repo = await Repo.create(DB_URL);
#7 · Both

Unbounded fan-out

Promise.all(hugeList.map(call)) or for … { go call() } over unbounded input works in dev with 10 items and falls over in prod with 100,000. Every fan-out over user-controlled or data-dependent input needs a limit (§11, pattern 3).

Staff-level§14

Running async systems in production

Little's Law: the one formula to remember

L = λ × Win-flight requests = arrival rate × latency

In words: how many requests are being worked on at any moment equals how many arrive per second × how long each one takes. At 2,000 requests/second taking 50 ms (0.05 s) each, you have 2,000 × 0.05 = ~100 requests in flight. If a dependency slows to 2 s, in-flight jumps to 4,000 — 40× the open sockets, goroutines/promises, buffers and memory. That's how a slow dependency becomes your outage. Timeouts, limits on concurrency and Load shedding aren't polish; they're how you survive this.

Measure the right things

Two numbers tell you when async code is in trouble. In Node: how late the event loop runs callbacks — measured as p99. In Go: how many goroutines exist — a number that only goes up means a leak.

observability
import { monitorEventLoopDelay } from "node:perf_hooks";

// How late does the event loop run callbacks? If the slowest 1% (p99)
// are > ~100 ms late, something is hogging the thread.
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => {
  // gauge = record "the current value" in your metrics system; / 1e6 converts ns → ms
  metrics.gauge("nodejs.eventloop.delay.p99_ms", h.percentile(99) / 1e6);
  h.reset();
}, 10_000).unref(); // unref(): this timer alone shouldn't keep Node running

Principles I hold teams to

🧵
Structured concurrencyEvery task has an owner — the owner waits for it and can cancel it. No orphans. Go: errgroup. JS: Promise.all + a shared AbortController.
⏳
Every I/O has a deadlineNo unbounded waits. Deadlines start where the request enters your system and are passed down, shrinking at each service call.
🚦
Bounded queues, explicit backpressureApply Backpressure: when full, reject fast (HTTP 429/503) instead of buffering forever. A queue without a bound is a memory leak with extra steps.
🏷️
Carry request contextPass the request's ID and the logged-in user through every async step: context.Context in Go, AsyncLocalStorage in Node.
🔁
Retries need budgetsJittered backoff, idempotency keys, and a cap on retry traffic so recovery doesn't become the next outage.
🔬
Observe the schedulerEvent-loop delay p99 in Node; goroutine count + pprof in Go. Alert on trends, not just thresholds.
Practitioner§15

Check yourself: predict the output

Commit to an answer before you click. If you miss one, re-run the event-loop visualizer with that rule in mind.

QUESTION 1 / 5
Timers vs promises
console.log(1);
setTimeout(() => console.log(2));
Promise.resolve().then(() => console.log(3));
console.log(4);
QUESTION 2 / 5
Where does an async function pause?
async function a() {
  console.log("a1");
  await b();
  console.log("a2");
}
async function b() { console.log("b"); }

a();
console.log("main");
QUESTION 3 / 5
When does the executor run?
const p = new Promise((resolve) => {
  console.log("executor");
  resolve("done");
});
console.log("after new");
p.then((v) => console.log(v));
QUESTION 4 / 5
Microtasks queued by microtasks
setTimeout(() => console.log("T"), 0);
Promise.resolve()
  .then(() => { console.log("P1"); queueMicrotask(() => console.log("Q")); })
  .then(() => console.log("P2"));
QUESTION 5 / 5
Go: what does this print?
func main() {
    for i := 0; i < 3; i++ {
        go fmt.Println(i)
    }
}
Answered 0 / 5
Foundations§16

Cheat sheet

⏸️
await pauses the function, not the threadThe code after the await is saved and runs when the promise finishes; meanwhile the thread does other work.
🎫
A Promise settles oncepending → fulfilled | rejected. Eager. Not cancellable — use AbortSignal / context.
🔄
Plain code → all promise follow-ups → one timer/eventThen repeat. That event-loop rule (§07) predicts every output-order puzzle.
🧩
Every await is an interleaving pointDon't put awaits between a check and the action that depends on it.
🐹
Go: blocking is cheapGoroutines + scheduler + netpoller. No function colors; pick concurrency at the call site with go.
🧮
Async ≠ parallelAsync helps I/O-bound work. CPU-bound work needs more cores (workers / goroutines).
⚡
Independent? Start them togetherPromise.all / errgroup — but always with a concurrency limit for big inputs.
🛡️
Deadlines, budgets, ownershipTimeouts everywhere, jittered retries on idempotent ops, and no orphaned tasks.
If you need…JavaScriptGo
Wait for all, fail fastPromise.allerrgroup.WithContext
Wait for all, keep every resultPromise.allSettledWaitGroup + []error
First to finishPromise.raceselect
Limit to N in flightmapLimit (§11) / p-limitg.SetLimit(n)
TimeoutAbortSignal.timeout(ms)context.WithTimeout
CPU-heavy workworker_threadsmore goroutines — Go spreads them over all cores
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Concurrency & AsyncChannels & select
Read →