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.
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.
await 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.)
Waiting is the enemy
A Promise is a receipt
await it when you need the value.Two philosophies
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:
// 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 };
}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
Thread
Operating system (OS)
I/O
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.
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:
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:
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.)
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:
ready list:
—
→
The same idea as a timeline:
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:
await points.GOMAXPROCS cores (default: all of them).| JavaScript (browser / Node) | Go | |
|---|---|---|
| Your code runs on | One main thread per page or Node process | Up to GOMAXPROCS threads at once (default: one per CPU core) |
| Unit of concurrency | Promise / async function | Goroutine |
| Parallelism | Only if you create worker threads | Built 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 |
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/catchandforloops 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
}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.
Promises: a state machine for a future value
Press the buttons. resolve(42) means "the work succeeded, here's the value"; reject(err) means "it failed":
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 namedresolveandreject: callresolve(value)when the work succeeds, orreject(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
.thencallback 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.) .thenreturns 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:
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)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:
Call stack
Microtask queue
.then callbacks and the code after an await. Emptied completely after every task.Task queue
// 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.
1console.log("A");2setTimeout(() => console.log("B"), 0);3Promise.resolve().then(() => console.log("C"));45async function run() {6 console.log("D");7 await null;8 console.log("E");9}1011run();12console.log("F");
"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:
1let balance = 100;23async function withdraw(n) {4 if (balance >= n) { // check5 await audit(n); // ⏸ pause6 balance -= n; // act (stale!)7 }8}910withdraw(80); // call A11withdraw(80); // call B
audit(n) stands for any slow async call — say, writing an audit log to a database.)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:
| API | Runs… |
|---|---|
process.nextTick(fn) | Even before promise microtasks — use sparingly |
Promise.then / await / queueMicrotask | Right 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 |
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.
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:
1async function getUserName(id) {2 const res = await fetch("/users/" + id);3 const user = await res.json();4 return user.name;5}67getUserName(42).then(console.log);
state number (which piece runs next). Piece 0 starts running right now.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:
Go: no async/await — on purpose
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:
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:
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:
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.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.
| JavaScript | Go | Notes |
|---|---|---|
async function f() | func f() | No special color. Run it concurrently with go f(). |
await p | Just call the function / v := <-ch | Blocking a goroutine is cheap. |
Promise<T> | <-chan T or (T, error) | A buffered channel of 1 is Go's "future". |
Promise.all | errgroup / sync.WaitGroup | errgroup.WithContext also cancels siblings on first error — better than JS! |
Promise.allSettled (wait for all, keep every result) | WaitGroup + a slice of per-item errors | Each 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 channel | First one ready wins. |
Promise.any (first success wins) | Loop receiving from a results channel, skipping errors | Stop when one succeeds or all fail. |
AbortController / AbortSignal | context.Context + cancel() | Go passes ctx as the first parameter by convention. |
AbortSignal.timeout(ms) | context.WithTimeout(ctx, d) | Always defer cancel(). |
setTimeout / sleep | time.After, time.NewTimer | Use inside select alongside ctx.Done(). |
try { await … } catch | if err != nil { … } | Errors are values in Go. |
| Unhandled rejection | Panic in a goroutine | Both 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 values | Data that belongs to one request — e.g. its ID, the logged-in user. |
| Event loop + libuv | Runtime scheduler + netpoller | Both sit on epoll / kqueue / IOCP. |
worker_threads | …just goroutines | Go is parallel by default. |
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.
// ❌ 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:
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| Combinator | Finishes when… | Use it for |
|---|---|---|
Promise.all | All succeed, or the first one fails (fail-fast) | Everything is required |
Promise.allSettled | All are done, success or failure — it never fails itself | Best-effort batches; reporting each item's result |
Promise.race | The first one finishes, success or failure | Timeouts (race the work against a timer) |
Promise.any | The 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:
// 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.
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:
// 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
}
}
}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.
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);
});Pitfalls gallery
Seven bugs I've seen in real code reviews, each with the fix.
await inside forEach
forEach ignores the promises your callback returns. "done" logs before anything is saved, and rejections go unhandled.
items.forEach(async (item) => {
await save(item);
});
console.log("done"); // runs first!// sequential, in order:
for (const item of items) await save(item);
// or concurrent:
await Promise.all(items.map(save));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.
app.get("/report", async (req, res) => {
const rows = await loadRows();
res.json(crunch(rows)); // sync & CPU-heavy: the whole server stalls
});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
});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.
async function load() {
try {
return fetchData(); // rejection escapes the try
} catch {
return FALLBACK; // never runs
}
}async function load() {
try {
return await fetchData(); // settles inside the try
} catch {
return FALLBACK; // ✅ works
}
}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.
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
}
}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
}
}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.
counts := map[string]int{}
for _, w := range words {
go func() { counts[w]++ }() // race!
}
// …and nobody waits for the goroutinesvar (
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()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.
class Repo {
constructor(url) {
this.ready = connect(url).then((c) => (this.conn = c));
}
query(sql) { return this.conn.query(sql); } // 💥 if not ready yet
}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);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).
Running async systems in production
Little's Law: the one formula to remember
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.
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 runningPrinciples I hold teams to
errgroup. JS: Promise.all + a shared AbortController.context.Context in Go, AsyncLocalStorage in Node.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.
console.log(1);
setTimeout(() => console.log(2));
Promise.resolve().then(() => console.log(3));
console.log(4);async function a() {
console.log("a1");
await b();
console.log("a2");
}
async function b() { console.log("b"); }
a();
console.log("main");const p = new Promise((resolve) => {
console.log("executor");
resolve("done");
});
console.log("after new");
p.then((v) => console.log(v));setTimeout(() => console.log("T"), 0);
Promise.resolve()
.then(() => { console.log("P1"); queueMicrotask(() => console.log("Q")); })
.then(() => console.log("P2"));func main() {
for i := 0; i < 3; i++ {
go fmt.Println(i)
}
}Cheat sheet
go.Promise.all / errgroup — but always with a concurrency limit for big inputs.| If you need… | JavaScript | Go |
|---|---|---|
| Wait for all, fail fast | Promise.all | errgroup.WithContext |
| Wait for all, keep every result | Promise.allSettled | WaitGroup + []error |
| First to finish | Promise.race | select |
| Limit to N in flight | mapLimit (§11) / p-limit | g.SetLimit(n) |
| Timeout | AbortSignal.timeout(ms) | context.WithTimeout |
| CPU-heavy work | worker_threads | more goroutines — Go spreads them over all cores |