Async — Cheat Sheet
JavaScript & TypeScript · 8 topics. Download the PDF or the Instagram carousel and share it.
The Event Loop — One Thread, No Blocking
JavaScript runs your code on a single thread. Asynchronous work is handed to the runtime and its callback is queued, so nothing runs concurrently with your code — it runs after it.
- ✓One thread runs your code — no data races, no locks, but also nothing else runs while you are busy
- ✓Microtasks (promises) drain completely before the next macrotask (timers, I/O callbacks)
- ✓setTimeout(fn, 0) means "after the current stack and all pending microtasks", not "now"
- ✓async/await yields control; it does not move work to another thread — CPU-bound code still blocks
- ✓For genuinely parallel CPU work you need a Web Worker; otherwise chunk and yield so the page can paint
console.log('1') // sync
setTimeout(() => console.log('2'), 0) // macrotask queue
Promise.resolve().then(() => console.log('3')) // microtask queue
console.log('4') // sync
// Output: 1, 4, 3, 2
//
// 1 and 4 run on the stack, in order.
// The stack empties.
// ALL microtasks drain: 3.
// Then one macrotask: 2.
// This is why setTimeout(fn, 0) is not "immediately":
// it is "after the current stack and all pending promises".Promises — States, Chaining and the Combinators
A promise is a value that will exist later. It has three states, settles exactly once, and chains through .then — and the four combinators cover almost every multi-request situation.
- ✓A promise settles exactly once — later resolve or reject calls are ignored
- ✓Every .then returns a new promise; forgetting to return inside one breaks the chain with undefined
- ✓Promise.all rejects on the first failure; allSettled never rejects and reports each outcome
- ✓race settles on the first result either way — which is what makes it a timeout; any waits for the first success
- ✓Awaiting independent requests in sequence is the most common async performance bug — use Promise.all
const p = fetch('/api/user') // pending
.then(res => res.json()) // returns a promise -> awaited
.then(user => user.name) // returns a value -> wrapped
.catch(err => 'anonymous') // handles anything above
.finally(() => setLoading(false)) // always runs, passes value through
// The classic bug — no return, so the chain gets undefined:
fetchUser()
.then(user => {
fetchOrders(user.id) // MISSING return
})
.then(orders => orders.length) // orders is undefined
// A promise settles once. Later calls are ignored:
new Promise((resolve, reject) => {
resolve('first')
resolve('second') // ignored
reject(new Error()) // ignored
})async / await — Promises That Read Like Sequential Code
async functions always return a promise; await pauses inside them until a promise settles. It is syntax over promises, so everything about promises still applies.
- ✓An async function always returns a promise, even when it returns a plain value
- ✓await suspends only the containing function — the rest of the program keeps running
- ✓try/catch works with await, but a forgotten await escapes it and becomes an unhandled rejection
- ✓Awaiting inside a loop is sequential; use Promise.all with map when the iterations are independent
- ✓Top-level await works in ES modules only, and for await consumes async iterables like paginated APIs
// async/await
async function loadUser(id) {
try {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return await res.json()
} catch (err) {
logError(err)
throw err // re-throw or the caller sees success
} finally {
setLoading(false)
}
}
// the same thing with promises
function loadUser(id) {
return fetch(`/api/users/${id}`)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
})
.catch(err => { logError(err); throw err })
.finally(() => setLoading(false))
}fetch — Talking to Your API
fetch is the built-in HTTP client. Its one surprising behaviour is that it does not reject on 404 or 500 — you have to check response.ok yourself.
- ✓fetch rejects only on network failure — a 404 or 500 is a successful fetch, so always check response.ok
- ✓Without the ok check, an HTML error page reaches res.json() and throws a confusing parse error
- ✓Do not set Content-Type when sending FormData — the browser must add the multipart boundary
- ✓Cross-origin cookies are not sent unless you pass credentials: "include"
- ✓AbortController cancels in-flight requests; aborting on cleanup is what stops a stale response overwriting a newer one
// GET
const res = await fetch('/api/problems?page=1')
// POST with JSON
const res = await fetch('/api/problems', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ title, difficulty }),
})
// File upload — do NOT set Content-Type; the browser adds the boundary
const form = new FormData()
form.append('file', fileInput.files[0])
await fetch('/api/upload', { method: 'POST', body: form })
// Cookies are not sent cross-origin unless you ask
await fetch(url, { credentials: 'include' })Errors — Throwing, Catching and Custom Types
Throw Error objects, not strings. Catch where you can act, not everywhere. And know the two ways an async error escapes a try/catch.
- ✓Throw Error objects, never strings — a string has no stack trace
- ✓Custom Error subclasses let callers branch on instanceof instead of parsing message text
- ✓A missing await turns a catchable rejection into an unhandled one, even inside try/catch
- ✓A throw inside setTimeout or another deferred callback cannot be caught by the surrounding try/catch
- ✓Catch where you can act; returning an empty list on failure makes an outage indistinguishable from no data
class ApiError extends Error {
constructor(message, status, body) {
super(message)
this.name = 'ApiError'
this.status = status
this.body = body
}
}
class ValidationError extends Error {
constructor(field, message) {
super(message)
this.name = 'ValidationError'
this.field = field
}
}
try {
await submit(form)
} catch (err) {
if (err instanceof ValidationError) highlight(err.field)
else if (err instanceof ApiError && err.status === 401) redirectToLogin()
else report(err)
}
// Preserving the original when re-throwing:
throw new ApiError('Checkout failed', 502, body, { cause: err })Timers, Debounce and Throttle
setTimeout and setInterval schedule work; debounce and throttle limit how often it happens. Both patterns are closures, and both need cleanup.
- ✓A timer delay is a minimum — the callback runs once the stack is clear, not at an exact time
- ✓setInterval runs until cleared; not clearing it on unmount is a real and common leak
- ✓Debounce fires once after calls stop — right for search-as-you-type and autosave
- ✓Throttle fires at most once per interval while calls continue — right for scroll and resize
- ✓For visual updates use requestAnimationFrame instead, since it synchronises with the browser paint
const id = setTimeout(fn, 1000)
clearTimeout(id)
const iid = setInterval(fn, 1000)
clearInterval(iid) // otherwise it runs forever
// The leak, in React:
useEffect(() => {
const id = setInterval(tick, 1000)
return () => clearInterval(id) // without this, every mount adds a timer
}, [])
// The delay is a floor. This does not print after exactly 100ms:
setTimeout(() => console.log('later'), 100)
blockFor(500) // the timer waits for the stack to clear
// Nested timers are clamped to >= 4ms after 5 levels of nesting.Async Patterns — Concurrency, Queues and Cancellation
Beyond a single request: limiting how many run at once, cancelling superseded work, and making retries safe.
- ✓Promise.all starts every task at once — use a concurrency pool for large lists
- ✓A slow earlier response can overwrite a newer one; fix it with AbortController or a sequence check
- ✓GET, PUT and DELETE are safe to retry; POST is not, without an idempotency key
- ✓An idempotency key lets the server recognise a repeat and return the original result instead of acting twice
- ✓Add jitter to backoff so retries from many clients do not arrive together
// All 500 at once — the server, or the browser's 6-connection limit, suffers
await Promise.all(ids.map(fetchOne))
// At most N in flight
async function pool(items, limit, worker) {
const results = []
const running = new Set()
for (const [i, item] of items.entries()) {
const p = Promise.resolve(worker(item, i))
.then(r => { results[i] = r })
.finally(() => running.delete(p))
running.add(p)
if (running.size >= limit) await Promise.race(running)
}
await Promise.all(running)
return results
}
const pages = await pool(urls, 5, url => fetch(url).then(r => r.json()))Streaming and Real-Time — SSE, WebSockets and ReadableStream
Three ways to get data as it arrives rather than all at once. Choosing between them is a design question you will be asked, and the answer is usually not WebSockets.
- ✓SSE is one-way over plain HTTP with automatic reconnection — the right default for server-to-client updates
- ✓WebSockets are full duplex but give you no reconnection, heartbeat or queueing; you build all of it
- ✓You cannot set headers on a WebSocket handshake, so authenticate with a cookie or a short-lived ticket
- ✓Reading res.body with a reader streams a response progressively, which is how token-by-token AI output works
- ✓Always close an EventSource and abort a stream on unmount, or the connection and the server work continue
const es = new EventSource('/api/jobs/42/progress')
es.onmessage = (e) => setProgress(JSON.parse(e.data))
es.addEventListener('done', () => es.close()) // named events
es.onerror = () => { /* the browser retries automatically */ }
// Cleanup matters — an open EventSource keeps the connection alive
useEffect(() => {
const es = new EventSource(url)
es.onmessage = handle
return () => es.close()
}, [url])
// The wire format is plain text:
// event: progress
// data: {"percent":40}
// \n
// which means any backend can produce it with no library.
// Limits: one direction only, and browsers cap ~6 connections
// per origin on HTTP/1.1 (not an issue over HTTP/2).