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.
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.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.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.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).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.queueMicrotask/setTimeout(0)), resolve each caller with its slice (DataLoader pattern); cap batch size.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.{ 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 with cause, AggregateError for batches, typed result objects ({ ok, value } | { ok, error }) where exceptions are control flow, never swallowed catches.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) }
}
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.
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.
limit workers run concurrently, results are ordered, and abort stops remaining work.retry with full jitter and a Retry-After header handler; test that 4xx errors are not retried.normalize, toTree and flatten, then a Vue tree component that renders the flattened list with indentation."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."
"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."
Nothing yet. Notes filed here show up on this page and the section overview.
JavaScript utilities
The utility-function drills that recur in senior frontend interviews — debounce and throttle, memoize, once, deep equal, deep clone and deep merge, groupBy and chunk, pick and omit, an event emitter, an LRU cache, retry — each with the edge cases that separate a working answer from a senior one, typed implementations and the built-ins that replace hand-written code in 2026.
Vue component drills
The component and composable builds that senior Vue interviews ask for in 30–45 minutes — a todo list with filters, an accessible autocomplete, a dialog with focus trapping, tabs, a sortable paginated table, an infinite list, a validated form — and the composables behind them (useDebounce, useLocalStorage, useIntersectionObserver, useEventListener), with the details reviewers look for.