asantool

Our PDF tools ran on the main thread for months

A one-line import meant to find the pdf.js worker was quietly disabling it. No error, no failed request — every PDF page on the site paid for it.

Bilal Arif

· 5 min read

A timeline diagram comparing parsing and rendering on the main thread with the same work moved onto a worker

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.

Tools in this article

Read next