Skip to content
← Journal
12 min readAta Mohammadi

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, fulfilled or rejected. 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:

  • await always yields, even on a non-promise. await 1 still costs a microtask tick, because the value goes through Promise.resolve first.
  • A thrown error inside the coroutine becomes a rejection, because next() is wrapped in try/catch by the driver.
  • try/finally across an await works, because the generator resumes inside the same lexical frame rather than in a new one — which is why finally blocks are reliable for cleanup in async code and why generator.throw exists 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 thread

The 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-corp

Check 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();
  1. The signal's aborted flag flips and an abort event is dispatched to its listeners.
  2. The network stack tears down the transfer — closing the connection, or sending RST_STREAM on HTTP/2 and HTTP/3. The bytes genuinely stop arriving.
  3. The fetch promise rejects with a DOMException named AbortError. 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

  • BroadcastChannel is 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.
  • SharedWorker is one worker instance shared by every same-origin context, connected through MessagePorts. 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:

  1. 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.
  2. Threads share nothing unless you make them. Copy (structured clone), move (transfer), or share (SharedArrayBuffer behind cross-origin isolation) — three costs, three trade-offs.
  3. Cancellation is a protocol, not a feature. AbortSignal is 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.

Next step

Tell us what is broken or what should exist.

Send the shape of the problem and any constraints you already know — budget, deadline, the stack you are stuck with. You will get a written reply from the engineer who would do the work, not a sales sequence.