Every PDF tool on this site uses pdf.js to read and draw documents. It is meant to do that on a Web Worker — a separate thread — so that a forty-page contract can be parsed without the page freezing while it happens.
For months, ours did not. It ran on the main thread, on every PDF page, and nothing anywhere said so.
How we found it
Not by reading the code. By watching a browser.
While testing the rebuilt PDF signer I had the console open, and one line went past that should not have been there:
Warning: Setting up fake worker.
A “fake worker” is pdf.js’s fallback: if it cannot get a real thread, it runs the worker’s code inline instead. Everything still works. Everything is just on the same thread as your interface.
The first thing worth checking was whether this was new. It was not:
| Page | Fake worker? |
|---|---|
/rotate-pdf |
yes |
/pdf-to-jpg |
yes |
/edit-pdf |
yes |
/sign-pdf |
yes |
Every page that touches pdf.js, all the way back.
The part that made no sense
The obvious explanations all failed one after another.
The worker file was being built — it was sitting in the output directory
with a hashed name, exactly as it should be. It was being served. The
Content Security Policy allowed it: worker-src 'self' blob:. There was no
error in the console beyond the warning itself, and no failed request.
Then the measurement that turned it around: the browser never asked for the file at all. Not a 404, not a blocked request — nothing. pdf.js was deciding, before it went anywhere near the network, that it did not want a worker.
The line that caused it
Here is the code that was meant to find the worker:
const worker = await import("pdfjs-dist/build/pdf.worker.mjs?url")
.then((m) => m.default)
.catch(() => null);
The ?url suffix is a convention some bundlers understand: do not run this
module, just give me the URL it will be served from. Our bundler does not
understand it. It ignored the suffix and treated the line as an ordinary
dynamic import — which means it did what an import does.
It ran the worker’s code. On the main thread.
And the first thing that code does when it runs is announce itself:
globalThis.pdfjsWorker = { WorkerMessageHandler };
pdf.js checks for exactly that. Finding a message handler already present on the page, it concludes that a worker environment has been set up by hand and politely uses it instead of spawning one of its own. No warning about that part, because from the library’s point of view nothing went wrong.
The line written to find the worker was what disabled it.
The fix
Delete it. What remains is the form the bundler does understand, which it rewrites at build time into the real hashed path:
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.mjs",
import.meta.url,
).toString();
Measured on the same 40-page document, before and after:
| before | after | |
|---|---|---|
| Real workers created | 0 | 2 |
globalThis.pdfjsWorker |
defined | undefined |
| Long tasks while opening it | 1 | 0 |
| Total blocking time | 13ms | 0ms |
Two workers rather than one because the viewer and the page-thumbnail strip each open the document — and now they genuinely run in parallel.
I want to be straight about the size of those numbers. Thirteen milliseconds is not a catastrophe, and the test documents were simple text on a fast Mac. The honest claim is not “the site got dramatically faster”; it is that the work moved off the thread your taps are waiting on, and on a mid-range phone with a 20MB scan that is the difference between a pause and a freeze.
What I would take from it
The failure mode was invisible, and that is the actual lesson. No error, no failed request, no broken page, no complaint. Every automated check we had passed, because the tools all still worked — they were just doing it in the wrong place. It survived months of shipping features on top of it.
It was only caught because something was being watched in a real browser rather than read in a diff. That habit has now found a flipped image export, a permanently disabled download button, a 0.106 layout shift, and this.
The code now carries a note saying so, because the next person to touch it will not be able to tell by looking:
The failure mode is invisible, so verify this by watching for a real Worker, not by reading the code.
The signer this turned up in is here, and it does its parsing on a worker now:
It is also worth saying what did not change: nothing about where your file goes. It never went anywhere. The whole reason pdf.js is doing this work in your browser is that the document does not get uploaded to us or to anyone else — which is the same reason a bug like this costs you a stutter rather than costing us a server bill.

