Cheat SheetsJavaScript & TypeScriptAsync

Async — Cheat Sheet

JavaScript & TypeScript · 8 topics. Download the PDF or the Instagram carousel and share it.

Cheat Sheet · AiCanCode.org
Async
JavaScript & TypeScript8 topicsQuick revision reference
1

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
Why the output is 1, 4, 3, 2
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".
2

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
Chaining, and the missing return
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
})
3

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
Same behaviour, two syntaxes
// 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))
}
4

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, JSON POST, file upload, cookies
// 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' })
5

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
Branch on type, not on message
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 })
6

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
setInterval leaks without cleanup
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.
7

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
A concurrency pool in a dozen lines
// 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()))
8

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
EventSource — the underused default
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).
Learn this free with Aria, your AI tutor → AiCanCode.org/learn/javascript