NearPDF

2026-07-20

One visitor, two race conditions: fixing a worker response bug and a screen-state race in NearPDF

NearPDF merges, splits, and edits PDFs entirely on your device — pdf-lib running inside a Web Worker, no upload, no backend, ever (see the project README's "zero uploads" line, which is also a hard constraint enforced by public/_headers's CSP). It's tempting to assume that "no server" also means "no concurrency bugs" — there's no request queue, no other tenants, no shared state between visitors. That assumption is wrong, and NearPDF's own test suite has the regression test to prove it: a single visitor, on a single page, clicking two buttons close together was enough to strand the UI forever. This is the story of that bug, which turned out to be two independent races that had to combine to produce it, and the fixes for both.

What the bug actually looked like

The reproduction is almost insultingly simple: add two PDFs, click merge, and — while that merge is still running in the Worker — add a third file. apps/nearpdf/tests/e2e-race.mjs describes the visible symptom plainly in its own header comment: "clicking merge and then adding another file before it finished used to leave the UI stuck on the workspace screen forever, even though the merge itself succeeded in the background." Not a crash, not an error toast — a silent hang. The merge worker did its job and posted a valid result back; the page just never acted on it. That's a specific, ugly kind of bug, because everything downstream of the UI looks fine in isolation: the PDF bytes are sitting in memory, correctly merged, with nothing wired up to notice.

The test file is also honest about how this was found: not by the main e2e suite, not by unit tests, not by reading the code and spotting it by inspection — it took an actual live reproduction, clicking through the real page in real Playwright Chromium, to surface it at all. Timing-dependent bugs like this one hide from anything that doesn't actually race the two operations against the wall clock.

Race #1: worker responses need their own identity, not a shared slot

NearPDF's merge engine runs in a Worker (src/merge.worker.js) and is called through makeWorker(), a small request/response bridge in packages/near-kit/src/runtime.js. Its header comment says plainly what shape of bug this replaced: an earlier, hand-rolled version (never actually shared as a kit helper — each app had its own copy) "keyed pending replies by a shared 'done'/'error' slot cleared on every message." Read that literally: one shared box for "the current in-flight call's result," reused for every call. Fire a second worker call while the first is still pending, and the second response arriving clears or overwrites that shared box — the first call's own handler is gone, with nothing left to ever resolve or reject its promise. It just hangs, silently, forever.

The current, fixed version is small enough to read in full. Every call gets its own incrementing id from a local seq counter, and the pending calls are tracked in a Map keyed by that id rather than a single shared slot:

export function makeWorker(url) {
  const worker = new Worker(url, { type: 'module' });
  const pending = new Map();
  let seq = 0;
  worker.onmessage = (e) => {
    const { id } = e.data;
    const handler = pending.get(id);
    if (!handler) return;
    pending.delete(id);
    if (e.data.type === 'error') handler.reject(new Error(e.data.message));
    else handler.resolve(e.data);
  };
  return (payload, transfers = []) => {
    const id = ++seq;
    return new Promise((resolve, reject) => {
      pending.set(id, { resolve, reject });
      worker.postMessage({ ...payload, id }, transfers);
    });
  };
}

The other half of this contract lives on the worker side: merge.worker.js's self.onmessage handler destructures id straight out of the incoming message and includes it on every single postMessage it sends back — the 'inspected' result, the 'merged' result, and, just as importantly, the 'error' case in its catch block. A response with no matching entry in pending (because it already resolved, or because of a stray message) is just ignored — if (!handler) return; — rather than silently grabbing the wrong caller's promise. Structurally, this is what makes it safe for NearPDF's app.js to fire an 'inspect' call for newly-added files while a 'merge' call from the button click is still outstanding: each call's response can only ever resolve that call's own promise. A response for call B landing while call A is still pending can no longer cross the wires between them.

Race #2: an await let a stale screen check act on the wrong state

Fixing the worker bridge alone didn't fully explain the symptom, because NearPDF's own intake() function (src/app.js, around lines 76-97) had a second, independent race sitting right next to it. intake() runs every time files are added — from the landing screen, or from the workspace after the first batch — and has to decide which of two very different things to do: call enterWorkspace() for the first files, or call addFiles() to append onto an already-built workspace. That decision depends on whether the user has "already entered" the workspace.

The natural-looking way to check that is to look at the DOM: is #screen-workspace currently hidden or not? The problem is exactly where that check used to run relative to an await. intake() has to await validateFiles(fileList, ...) before it knows which of the dropped files are real, accepted PDFs — and while that await is pending, the rest of the page keeps running. If a merge that was already in flight finishes during that window and calls show('screen-done'), the DOM now says "workspace is hidden, we're on the done screen" — a fact that's true, but true for a reason that has nothing to do with the add currently in progress. A screen check performed after the await would read that new state and wrongly conclude "we haven't entered the workspace yet," calling enterWorkspace() — which itself calls show('screen-workspace') and stomps the merge's just-completed transition to the done screen right back to workspace, discarding the user's view of a result that had, in fact, already finished successfully.

The fix is one line moved to the right place — capture the fact synchronously, before the await, instead of re-deriving it from the DOM afterward:

async function intake(fileList) {
  const fromIdx = state.files.length;
  // Captured synchronously, BEFORE the validateFiles() await below — checking
  // $('screen-workspace').hidden AFTER that await was racy...
  const alreadyEntered = fromIdx > 0;
  const { accepted, errors } = await validateFiles(fileList, ...);
  ...
  if (!alreadyEntered) await enterWorkspace();
  else await addFiles(fromIdx);
}

state.files.length is updated synchronously by every earlier intake() call, so reading it before the await captures the truth as of the moment this specific add was initiated — not whatever screen the page happens to be showing by the time the async validation work finishes. It's a small change with an easy-to-state general shape: once you await something, any fact you read afterward has to be re-derived from state that's still valid at that later moment, not assumed to still match the state as it was when the function started.

Why it took both bugs to produce the visible symptom

Neither race alone was NearPDF's whole story here — the two had to combine. Race #1 is what makes it safe to have a merge call and an inspect call in flight at once at all; without the fix, firing the second call could wipe out the first call's handler directly. Race #2 is what happens once both calls are allowed to be in flight together and the merge's completion happens to land in the exact window intake() is awaiting validation: the completed merge's own screen transition gets silently reverted by a decision based on stale state. Fixing only one of the two would still have left a way to strand the UI; both had to be addressed, because the reproduction that exposed them exercises the interaction, not either mechanism in isolation.

Proving it, not just believing it

e2e-race.mjs doesn't run the reproduction once and call it done — the bug it targets is timing-dependent, and the file says so directly: "the bug was timing-dependent (~40% fail rate pre-fix) — a single pass proves little." So the test drives the real sequence fifteen times in a row against a real built page in real Playwright Chromium — add two files, wait for the workspace and its thumbnails to render, click merge, immediately add a third file while that merge is still running, then wait for #screen-done to appear within an 8-second timeout. Each of the fifteen runs is graded independently and the whole test only passes if all fifteen reach screen-done. A bug that only reproduced 4 times out of 10 would sail through a test that only tried once; running it fifteen times and requiring every single one to pass is what makes "fixed" mean something more than "didn't happen to fail today."

The honest summary

Concurrency bugs don't need a server, and they don't need other users — a single visitor clicking merge and then dragging in one more file before it finishes is enough, entirely inside one browser tab, with zero network requests involved. NearPDF's fix wasn't one change but two, addressing two genuinely independent races that happened to compound into a single visible hang: worker responses are now correlated by a per-call id instead of a shared, clearable slot, and intake() now decides its branch from state captured before its await instead of state re-read after it. Both are small, structural fixes — the kind that don't just patch the one reproduction that got caught, but remove the entire shape of bug that let it happen, whether the next trigger looks exactly like this one or not.

Sponsored
← NearPDF

This page shows ads only if you consent.