A pdf.js render stops when the tab is hidden
A three page PDF took 32.8 seconds to turn into images. The same three pages now take 0.5. Almost none of the difference was computation. It was a render waiting for animation frames that were never going to arrive, because the browser window was behind a terminal.
The symptom, which looks like nothing
Start a render, switch to another window, come back a minute later. The progress bar has not moved. There is no error in the console, no rejected promise, no failed request, and no timeout. The promise returned by page.render().promise is simply still pending, and will be for as long as you leave it.
Every instinct is wrong here. It reads as a deadlock, or a corrupt file, or your own code awaiting something that never resolves. We diagnosed it as all three, in that order, and shipped a revert for a regression that turned out not to exist.
The cause, in one line of pdf.js
From pdfjs-dist 6.2.108, InternalRenderTask:
this._useRequestAnimationFrame = !intentPrint && typeof window !== "undefined"; // and in _scheduleNext(): if (this._useRequestAnimationFrame) window.requestAnimationFrame(...) else Promise.resolve().then(...)
pdf.js paints a page in chunks and schedules the next chunk on requestAnimationFrame, which is the right thing to do on a thread that also has to keep a page responsive. The catch is that browsers stop firing animation frames for a document that is not being displayed. Not throttle: stop. So the render pauses between chunks, indefinitely, and resumes the moment you look at it again, which is also why it is so easy to convince yourself it was never stuck.
This is not a bug in pdf.js. A viewer is on screen by definition. It is a bug in using a viewer as a converter.
Which makes the measuring instrument the second problem
We timed this by polling the page from a devtools evaluate call in a loop. That poll runs on the same main thread the render is on, so it competed with the thing it was measuring, and the evaluate itself timed out with the renderer may be frozen or unresponsive. Which we then reported as a freeze.
Two rules came out of that, and they are the cheap half of this post. Never time a render from a window that is behind another window. And never poll page state from the thread doing the work, because a measurement that contends with its subject reports contention.
The fix is a worker, and the worker had a trap of its own
In a worker typeof window === "undefined", so that same line picks the microtask branch and the render finishes whatever the tab is doing. Canvases come from OffscreenCanvas, fonts from the worker's own self.fonts, and filters have to be declined, because pdf.js implements them as SVG elements and a worker has nowhere to put one.
Then the worker did not run, and reported nothing at all. The reason is the best part of this. pdf.js cannot start its own parsing worker from inside a worker, so it falls back to importing the parser into the current scope. That parser module ends with a static block:
static {
"undefined" == typeof window && ... && this.initializeFromPort(self)
}It looks around, sees a worker scope, concludes that it is the parser worker, and posts { action: "ready" } to its parent. Its parent is your main thread. So the first message your render worker appears to send is one it did not send, in a protocol you did not design, before a single page is drawn.
Our listener treated anything it did not recognise as a failure, terminated the worker and quietly fell back to the main thread. Which stalls in a hidden tab. So the worker was killed in its first second on every run, and every number we measured for two days, the successes and the failures alike, was the main thread. The fix is one clause: act on your own envelope and ignore everything else.
What it was worth
A 51 KB, three page PDF at 300 DPI, in Chrome, with the window deliberately behind the terminal:
main thread 32.8 s, and only because something kept giving it frames worker 0.5 s
The old number was mostly waiting. Checked with poppler rather than with the thing that produced it: the saved page is 2550 by 3300 and carries its text and fonts.
If you render PDFs in a browser, check this one thing
Start a conversion, switch to another application for thirty seconds, and come back. If the progress is exactly where you left it, your render is on the main thread and every one of your users who changed tabs is waiting on nothing. It is the most common thing a person does while a file converts.
The tools this came out of
Both render pages with pdf.js in a worker, in your browser, with nothing uploaded.
filetity is built by Adarsh Mishra. The pdf.js lines above are from pdfjs-dist 6.2.108, read on 2026-09-12, and every timing was measured rather than estimated. If yours behaves differently, that is worth knowing: support@filetity.com.