Senior Frontend Interview Prep, Part 6: Async, Promises, Workers and Shared Memory
Fifteen questions on concurrency: promise internals, what `await` desugars to, cancellation that actually cancels, transferables and zero-copy, `SharedArrayBuffer` under cross-origin isolation, and a task pool you can paste into production.
Concurrency is where frontend interviews stop being about the language and start being about systems. Anyone can chain a .then(). The questions that separate candidates are about what a promise is in the specification, what cancellation really cancels, and what it costs to move a megabyte between two threads.
Promises
76. What are a promise's internal slots, and how are reactions scheduled?
Three slots:
[[PromiseState]]—pending,fulfilledorrejected. It transitions exactly once, and never back.[[PromiseResult]]— the value or the reason.[[PromiseFulfillReactions]]/[[PromiseRejectReactions]]— lists of records waiting for settlement.
Calling .then() on a pending promise appends a reaction record to the appropriate list. When the promise settles, it walks the relevant list, wraps each record in a PromiseReactionJob, and enqueues those jobs on the microtask queue.
Calling .then() on an already settled promise enqueues the job immediately — but still as a job, never synchronously. That guarantee ("a then callback never runs in the same turn as the call that registered it") is deliberate: it means a function that sometimes has a cached value and sometimes does I/O behaves identically either way. Without it, you get the release-Zalgo class of bug where a rare cache hit changes execution order and breaks everything downstream.
77. What does async/await desugar to?
An async function is a coroutine: a generator plus a driver that feeds it promises. Written out, the driver is about fifteen lines.
type Step<T> = () => IteratorResult<unknown, T>;
export function spawn<T>(makeGenerator: () => Generator<unknown, T, unknown>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const generator = makeGenerator();
function step(next: Step<T>): void {
let result: IteratorResult<unknown, T>;
try {
result = next();
} catch (error) {
reject(error);
return;
}
if (result.done) {
resolve(result.value);
return;
}
// Each `await` becomes: resolve the yielded value, then resume the
// coroutine in a microtask with the result — or throw back into it.
Promise.resolve(result.value).then(
(value) => step(() => generator.next(value)),
(error) => step(() => generator.throw(error)),
);
}
step(() => generator.next());
});
}Three things follow directly from this shape, and all three get asked:
awaitalways yields, even on a non-promise.await 1still costs a microtask tick, because the value goes throughPromise.resolvefirst.- A thrown error inside the coroutine becomes a rejection, because
next()is wrapped intry/catchby the driver. try/finallyacross anawaitworks, because the generator resumes inside the same lexical frame rather than in a new one — which is whyfinallyblocks are reliable for cleanup in async code and whygenerator.throwexists at all.
78. all, allSettled, race, any
| Fulfils when | Rejects when | Behaviour to name | |
|---|---|---|---|
Promise.all |
every input fulfils | any input rejects | Fails fast, but the others keep running — rejection is not cancellation |
Promise.allSettled |
every input settles | never | Array of { status, value } / { status, reason } |
Promise.race |
first input settles | first settlement is a rejection | The losers keep running and can still throw unhandled |
Promise.any |
first input fulfils | all inputs reject | Collects an AggregateError with .errors |
The insight that gets you credit: none of these cancel anything. Promise.all rejecting does not stop the other seven requests; Promise.race resolving does not abort the slow one. If you want the losers stopped, you pass them an AbortSignal — which is exactly what AbortSignal.any() is for:
async function fetchWithTimeout(url, ms) {
const timeout = AbortSignal.timeout(ms);
return fetch(url, { signal: timeout });
}
// Or combine: abort when either the user cancels or the deadline passes.
const signal = AbortSignal.any([userController.signal, AbortSignal.timeout(5000)]);79. How does the engine track unhandled rejections?
When a promise rejects with an empty reject-reaction list, it is put on a pending-unhandled set. If the microtask queue drains and nothing has attached a handler, the host fires unhandledrejection — on window in a browser, on process in Node. Attaching a handler later fires rejectionhandled, which is how a late .catch() retracts the warning.
Two practical points. In Node, an unhandled rejection terminates the process by default, which is correct behaviour and surprises people migrating old code. And the check happens at the end of the microtask drain, so const p = doWork(); await somethingElse(); p.catch(handle); can produce a spurious warning even though you do handle it — attach the handler in the same turn, and store the promise if you need to await it later.
80. Write Promise.all from scratch
The classic whiteboard exercise. What is being tested is not the loop — it is whether you preserve ordering with unordered completion, and whether you know to route everything through Promise.resolve.
export function promiseAll<T>(inputs: Iterable<T | PromiseLike<T>>): Promise<T[]> {
return new Promise<T[]>((resolve, reject) => {
const items = [...inputs];
const results = new Array<T>(items.length);
let remaining = items.length;
if (remaining === 0) {
resolve(results);
return;
}
items.forEach((item, index) => {
// Promise.resolve adopts foreign thenables and wraps plain values, so
// a non-promise input and a jQuery deferred both behave correctly.
Promise.resolve(item).then((value) => {
// Index, not push: completion order is not input order.
results[index] = value;
remaining -= 1;
if (remaining === 0) resolve(results);
}, reject);
});
});
}Points worth volunteering: the empty-iterable case resolves immediately (and must, or the promise hangs); remaining is a counter rather than a comparison against results.length, because a sparse array's length lies; and reject is passed directly as the second argument rather than wrapped in .catch(), so a throw inside the fulfil handler does not get caught by our own rejection path.
Threads
81. How does data cross into a worker?
A dedicated worker is a real OS thread with its own heap, its own stack and its own event loop. It shares nothing with the page by default.
worker.postMessage(data) serialises the object graph with the structured clone algorithm, copies the bytes, and reconstructs a fresh graph on the other side. Mutating your copy afterwards does nothing to theirs.
The cost is proportional to the graph, and it is paid synchronously on the sending thread. Posting a 30 MB object is a several-millisecond main-thread stall before the worker has done anything at all — which is exactly the jank you were trying to avoid.
82. Transferable objects and zero copy
For binary data, do not copy — transfer ownership:
const buffer = new ArrayBuffer(64 * 1024 * 1024);
worker.postMessage({ buffer }, [buffer]); // second argument: the transfer list
console.log(buffer.byteLength); // 0 — detached on this threadThe page table entries move; no bytes are copied. The sender's ArrayBuffer is detached, and every typed-array view over it throws on access. Transferable types include ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas and the WebCodecs frame types.
The design consequence to state: transfer makes ownership explicit and single. If you need the buffer on both sides, you either copy it deliberately or you need shared memory — which is the next question.
83. SharedArrayBuffer and Atomics
A SharedArrayBuffer maps the same physical memory into multiple agents. No copy, no transfer, genuine shared state — and therefore genuine data races.
Atomics provides the primitives that make it safe:
Atomics.add,Atomics.sub,Atomics.and,Atomics.compareExchange— uninterruptible read-modify-write.Atomics.load/Atomics.store— reads and writes with the memory ordering guarantees the optimiser must respect.Atomics.wait/Atomics.notify— a futex. A thread sleeps on an address until another thread writes and notifies.
// In a worker: block until the main thread publishes a new frame index.
const control = new Int32Array(sharedBuffer, 0, 1);
while (true) {
Atomics.wait(control, 0, lastSeen); // sleeps, burns no CPU
lastSeen = Atomics.load(control, 0);
render(lastSeen);
}Atomics.wait cannot be called on the browser's main thread — it would block the UI with no way out. Use Atomics.waitAsync there, which returns a promise instead.
84. Why was SharedArrayBuffer disabled, and what brings it back?
Spectre, in 2018. Speculative execution leaves measurable traces in CPU caches, and to exploit that you need a high-resolution clock. SharedArrayBuffer plus a worker incrementing a counter in a tight loop is a high-resolution clock — arbitrarily precise, regardless of what performance.now() is rounded to. Browsers disabled shared memory and coarsened timers in response.
It comes back only in a cross-origin isolated context, which requires the document to serve both:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpCheck self.crossOriginIsolated at runtime before assuming it worked.
And know the operational cost, because this is the part that decides whether you can ship it: require-corp means every cross-origin subresource must opt in with Cross-Origin-Resource-Policy or be fetched as CORS. Your CDN images, your analytics pixel, your third-party fonts, your embedded YouTube player — each one either opts in or breaks. credentialless relaxes this for no-cors requests by stripping credentials, and is usually the pragmatic middle ground. Being able to say "the blocker is never the header, it is the fourth-party embed that nobody owns" is the answer of someone who has actually attempted it.
85. OffscreenCanvas
A normal <canvas> is a DOM element, so every draw call runs on the main thread. A 40 ms frame means dropped input for 40 ms.
canvas.transferControlToOffscreen() produces an OffscreenCanvas you can transfer to a worker. The worker gets its own rendering context, runs its own requestAnimationFrame loop, and issues draw calls straight to the compositor. The main thread is free.
// main thread
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);Once transferred, the main thread cannot draw to that canvas at all — ownership moved. Which is why the pattern is "hand over the canvas at startup and communicate by messages afterwards", not "draw from both sides".
86. How does AbortController propagate?
const controller = new AbortController();
const response = await fetch(url, { signal: controller.signal });
controller.abort();- The signal's
abortedflag flips and anabortevent is dispatched to its listeners. - The network stack tears down the transfer — closing the connection, or sending
RST_STREAMon HTTP/2 and HTTP/3. The bytes genuinely stop arriving. - The
fetchpromise rejects with aDOMExceptionnamedAbortError. If a body stream was being read, it errors too.
The piece people miss: AbortSignal is a general-purpose cancellation protocol, not a fetch feature. addEventListener accepts { signal }. So do the Web Streams APIs, MediaRecorder, and — since Node 16 — a large part of the Node standard library. Building your own async function to accept a signal costs three lines and makes it composable with everything else:
export function delay(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason);
return;
}
const timer = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(signal.reason);
},
{ once: true },
);
});
}Note signal.reason rather than a hand-made error: it carries whatever the caller passed to abort(), defaulting to an AbortError.
87. Async generators and backpressure
async function* chunks(stream) {
const reader = stream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
yield value; // execution stops here until the consumer asks for more
}
} finally {
reader.releaseLock(); // runs on break, throw, or early `return` from the consumer
}
}
for await (const chunk of chunks(response.body)) {
await handle(chunk); // the producer is idle for exactly this long
}Backpressure is automatic because the protocol is pull, not push. The generator is suspended at yield and does not call reader.read() again until the consumer's next() arrives. A slow consumer produces a slow producer, with no buffer growing in between — which is the entire difference from an event-emitter design, where a slow consumer produces an unbounded queue.
The finally block is load-bearing: breaking out of the for await loop calls the generator's return(), which resumes it at the yield just long enough to run finally. That is how cleanup happens on early exit.
88. Web Workers, Service Workers, worklets
| Dedicated Worker | Service Worker | Worklet | |
|---|---|---|---|
| Purpose | CPU work off the main thread | Network proxy, offline, push | Real-time audio / paint / layout hooks |
| Lifetime | Tied to the page that created it | Outlives every tab; woken by events | Bound to the rendering or audio pipeline |
| DOM | No | No | No |
| Scope | One page | One origin scope path | One pipeline |
| Gotcha | postMessage cost is real |
Must be idempotent; can be killed mid-task | Hard real-time; an overrun is an audible glitch |
The point to make about service workers specifically is that they are event-driven and killable. The browser starts one when a fetch or push arrives and terminates it when idle, so module-level state does not survive. Anything that must persist goes in IndexedDB or the Cache API, and any long task needs event.waitUntil() to keep the worker alive.
89. BroadcastChannel and SharedWorker
BroadcastChannelis a same-origin pub/sub bus. Any context — tab, iframe, worker — can post, and every other context on the channel receives it. No coordinator, no shared state, no delivery guarantee to contexts that are not listening yet.SharedWorkeris one worker instance shared by every same-origin context, connected throughMessagePorts. It has one heap, so it can hold authoritative state: a single WebSocket for the whole browser, a token refresh that runs once rather than in five tabs at the same time, a lock.
Pick BroadcastChannel for "tell the other tabs something happened" and SharedWorker for "only one tab should be doing this". Worth mentioning as a third option: navigator.locks (the Web Locks API) solves the leader-election case on its own, with far less machinery, and is supported everywhere that matters.
90. Write a concurrency-limited task pool
The other classic whiteboard exercise. It shows up because unbounded Promise.all over a thousand URLs is a real production incident, not a hypothetical.
export class TaskPool {
#active = 0;
readonly #waiting: Array<() => void> = [];
constructor(private readonly limit: number) {
if (!Number.isInteger(limit) || limit < 1) {
throw new RangeError("limit must be a positive integer");
}
}
run<T>(task: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const start = () => {
this.#active += 1;
// The task factory itself may throw synchronously; Promise.resolve
// routes that into the rejection path instead of the caller's stack.
Promise.resolve()
.then(task)
.then(resolve, reject)
.finally(() => {
this.#active -= 1;
this.#waiting.shift()?.();
});
};
if (this.#active < this.limit) start();
else this.#waiting.push(start);
});
}
/** Convenience: map over inputs with the pool's limit applied. */
map<I, O>(items: readonly I[], fn: (item: I, index: number) => Promise<O>): Promise<O[]> {
return Promise.all(items.map((item, index) => this.run(() => fn(item, index))));
}
}The details an interviewer is listening for: the finally runs on both paths so a rejected task still frees its slot; the queue is drained in FIFO order; and Promise.resolve().then(task) rather than task() so that a factory throwing synchronously rejects the returned promise instead of escaping to the caller.
The shape of the whole section
Three sentences hold it together:
- A promise is a state machine with a reaction list, and every reaction runs as a microtask — always, even when the answer was already there.
- Threads share nothing unless you make them. Copy (structured clone), move (transfer), or share (
SharedArrayBufferbehind cross-origin isolation) — three costs, three trade-offs. - Cancellation is a protocol, not a feature.
AbortSignalis the protocol; anything that does not accept one cannot be cancelled, only ignored.
Last in the JavaScript volume: the specification's sharp edges — ToPrimitive, coercion, NaN, -0, and the ASI rules that eat a return value for breakfast.