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.