Coding drills

Async and data processing

[object Object]

Frontends orchestrate: fetch these twenty things but no more than four at a time, give up after two seconds, retry the flaky one, cancel when the user types again, and shape what comes back into what the UI needs. These drills test whether you understand promises, the event loop and cancellation well enough to write code that does not leak, race or hang.

Know

  • Promise pool (concurrency limit): start limit workers that each pull the next task from a shared index until none remain; preserve result order by index; fail fast or collect errors as agreed; accept an AbortSignal. This is p-limit/p-map in library form.
  • Promise.all by hand: counter of settled promises, results array by index, reject on first rejection, resolve with [] for empty input, wrap values with Promise.resolve so non-promises work. allSettled collects { status, value | reason }; race settles with the first; any rejects with AggregateError only when all reject.
  • Timeout and cancellation: AbortSignal.timeout(ms) and AbortSignal.any([signal, timeout]) for fetch; for arbitrary promises, Promise.race with a timer that is cleared on settle; cancellation cleans up listeners and timers. In Vue, abort in onScopeDispose/onUnmounted; in useAsyncData/useFetch, pass signal from the handler.
  • Retry: attempts, baseDelay * factor ** attempt capped, full jitter (random * delay) to avoid thundering herds, a shouldRetry(error) predicate (network errors and 5xx/429 yes, 4xx no), honour Retry-After, stop on abort.
  • Deduplicating in-flight requests: a Map<key, Promise> of pending calls, deleted on settle, so concurrent identical requests share one promise (Nuxt does this for useAsyncData keys; cachedFunction does it on the server).
  • Async iteration: for await over async generators for paginated APIs (yield each page or item, stop on no cursor), Array.fromAsync to collect, ReadableStream iteration for SSE or large downloads, backpressure by awaiting the consumer.
  • Batching: collect calls within a tick or window and issue one request (queueMicrotask/setTimeout(0)), resolve each caller with its slice (DataLoader pattern); cap batch size.
  • Sequential vs parallel: for ... of with await is sequential (use when order or rate matters); Promise.all(map) is parallel; a pool sits between; reduce with promises is a common anti-pattern that hides sequential intent.
  • Data shaping: normalise arrays into { byId, allIds } for O(1) lookup and stable updates; build a tree from a flat parent-id list in O(n) with a Map; flatten a tree with a stack (iterative DFS) to avoid recursion limits; aggregate with Map or Object.groupBy; immutable updates with spread, with, toSpliced, or structuredClone for deep changes; dates as ISO strings at the boundary and Temporal or Date inside.
  • Error handling: Error with cause, AggregateError for batches, typed result objects ({ ok, value } | { ok, error }) where exceptions are control flow, never swallowed catches.

How it works

A concurrency pool with ordered results, fail-fast and abort support:

export async function pool<T, R>(items: readonly T[], limit: number, worker: (item: T, index: number, signal: AbortSignal) => Promise<R>, signal?: AbortSignal): Promise<R[]> {
  const results = new Array<R>(items.length)
  const controller = new AbortController()
  const onAbort = () => controller.abort(signal?.reason)
  signal?.addEventListener('abort', onAbort, { once: true })
  let next = 0

  async function run() {
    while (next < items.length) {
      if (controller.signal.aborted) throw controller.signal.reason ?? new DOMException('Aborted', 'AbortError')
      const i = next++
      results[i] = await worker(items[i]!, i, controller.signal)
    }
  }

  try {
    await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run)) // rejects on first failure
    return results
  }
  catch (err) {
    controller.abort(err) // stop the other workers' in-flight fetches
    throw err
  }
  finally {
    signal?.removeEventListener('abort', onAbort)
  }
}

// usage: const pages = await pool(urls, 4, (url, _, signal) => $fetch(url, { signal }))

Promise.all and allSettled from scratch:

export function promiseAll<T>(inputs: Iterable<T | PromiseLike<T>>): Promise<Awaited<T>[]> {
  return new Promise((resolve, reject) => {
    const items = [...inputs]
    const results = new Array<Awaited<T>>(items.length)
    let remaining = items.length
    if (remaining === 0) return resolve(results)
    items.forEach((item, i) => {
      Promise.resolve(item).then((value) => {
        results[i] = value as Awaited<T>
        if (--remaining === 0) resolve(results)
      }, reject)
    })
  })
}

export function promiseAllSettled<T>(inputs: Iterable<T | PromiseLike<T>>) {
  return promiseAll([...inputs].map(p => Promise.resolve(p).then(
    value => ({ status: 'fulfilled' as const, value }),
    reason => ({ status: 'rejected' as const, reason }),
  )))
}

Timeout, retry with backoff and jitter, and in-flight deduplication:

export function withTimeout<T>(promise: Promise<T>, ms: number, signal?: AbortSignal): Promise<T> {
  const { promise: timeout, reject } = Promise.withResolvers<never>()
  const timer = setTimeout(() => reject(new DOMException(`Timed out after ${ms}ms`, 'TimeoutError')), ms)
  signal?.addEventListener('abort', () => reject(signal.reason), { once: true })
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
}

export async function retry<T>(task: (attempt: number, signal?: AbortSignal) => Promise<T>, { attempts = 3, baseDelay = 200, factor = 2, maxDelay = 5_000, shouldRetry = (e: unknown) => isTransient(e), signal }: { attempts?: number, baseDelay?: number, factor?: number, maxDelay?: number, shouldRetry?: (e: unknown) => boolean, signal?: AbortSignal } = {}): Promise<T> {
  for (let attempt = 0; ; attempt++) {
    try {
      return await task(attempt, signal)
    }
    catch (err) {
      if (signal?.aborted || attempt + 1 >= attempts || !shouldRetry(err)) throw err
      const delay = Math.min(maxDelay, baseDelay * factor ** attempt) * Math.random() // full jitter
      await sleep(delay, signal)
    }
  }
}

const isTransient = (e: unknown) => e instanceof TypeError /* network */ || (typeof (e as any)?.status === 'number' && ((e as any).status >= 500 || (e as any).status === 429))
const sleep = (ms: number, signal?: AbortSignal) => new Promise<void>((resolve, reject) => {
  const t = setTimeout(resolve, ms)
  signal?.addEventListener('abort', () => { clearTimeout(t); reject(signal.reason) }, { once: true })
})

export function dedupeInFlight<A extends string, R>(fn: (arg: A) => Promise<R>) {
  const inflight = new Map<A, Promise<R>>()
  return (arg: A) => {
    let p = inflight.get(arg)
    if (!p) {
      p = fn(arg).finally(() => inflight.delete(arg))
      inflight.set(arg, p)
    }
    return p
  }
}

Paginated API as an async generator, and micro-batching:

async function* paginate<T>(url: string, signal?: AbortSignal) {
  let cursor: string | undefined
  do {
    const page = await $fetch<{ items: T[], next?: string }>(url, { query: { cursor }, signal })
    yield* page.items
    cursor = page.next
  } while (cursor)
}
const first100 = await Array.fromAsync(paginate<Order>('/api/orders').take?.(100) ?? paginate<Order>('/api/orders')) // iterator helpers where available

export function createBatcher<K extends string, V>(load: (keys: K[]) => Promise<Map<K, V>>, { maxSize = 50 } = {}) {
  let queue: { key: K, resolve: (v: V | undefined) => void, reject: (e: unknown) => void }[] = []
  let scheduled = false
  const flush = async () => {
    const batch = queue.splice(0, maxSize); scheduled = queue.length > 0
    if (scheduled) queueMicrotask(flush)
    try { const map = await load(batch.map(b => b.key)); batch.forEach(b => b.resolve(map.get(b.key))) }
    catch (e) { batch.forEach(b => b.reject(e)) }
  }
  return (key: K) => new Promise<V | undefined>((resolve, reject) => {
    queue.push({ key, resolve, reject })
    if (!scheduled) { scheduled = true; queueMicrotask(flush) }
  })
}

Data shaping: normalise, tree from flat list, flatten with a stack:

type Row = { id: string, parentId: string | null, name: string }
type Node = Row & { children: Node[] }

export function toTree(rows: Row[]): Node[] {
  const byId = new Map<string, Node>(rows.map(r => [r.id, { ...r, children: [] }]))
  const roots: Node[] = []
  for (const node of byId.values()) {
    const parent = node.parentId ? byId.get(node.parentId) : undefined
    ;(parent ? parent.children : roots).push(node) // orphans become roots; decide with the interviewer
  }
  return roots
}

export function flatten(roots: Node[]): (Row & { depth: number })[] {
  const out: (Row & { depth: number })[] = []
  const stack = roots.map(n => ({ node: n, depth: 0 })).reverse()
  while (stack.length) {
    const { node, depth } = stack.pop()!
    const { children, ...row } = node
    out.push({ ...row, depth })
    for (let i = children.length - 1; i >= 0; i--) stack.push({ node: children[i]!, depth: depth + 1 })
  }
  return out
}

export function normalize<T extends { id: string }>(items: T[]) {
  return { byId: Object.fromEntries(items.map(i => [i.id, i])) as Record<string, T>, allIds: items.map(i => i.id) }
}
Gotcha· A pool that keeps working after the first failure

Promise.all rejects immediately, but the other workers keep pulling tasks unless you abort them, so the "failed" pool keeps hitting the API. Share an AbortController with the workers and abort it in the catch.

Gotcha· Retrying non-idempotent requests

Retrying a POST /orders on a timeout can create two orders, because the first may have succeeded after the client gave up. Retry only idempotent requests, or send an Idempotency-Key the server honours.

Exercise

Exercise
  • Implement the pool and test with Vitest fake timers that at most limit workers run concurrently, results are ordered, and abort stops remaining work.
  • Write retry with full jitter and a Retry-After header handler; test that 4xx errors are not retried.
  • Take a real API list response and write normalize, toTree and flatten, then a Vue tree component that renders the flattened list with indentation.

Be able to say

Be able to say· Fetch a hundred URLs but no more than five at a time. How?

"A worker pool: I create five async workers that each take the next index from a shared counter, await the fetch, store the result by index so order is preserved, and loop until the list is exhausted; Promise.all over the workers gives me the results or the first error. I share an AbortController with every fetch so that on the first failure or a caller's abort the in-flight requests stop instead of continuing to hit the API. That is what p-limit and p-map do, and I would use them in production, but the hand-written version is fifteen lines."

Be able to say· How do you implement retry correctly?

"Bounded attempts, exponential backoff with a cap, and full jitter so many clients do not retry in lockstep, a predicate that retries only transient failures such as network errors, 5xx and 429 while never retrying 4xx, honouring Retry-After, and an AbortSignal so a cancelled operation stops sleeping. And only for idempotent requests, or with an idempotency key, because a timed-out POST may have succeeded."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.