\n\n\n\n\nHeadless Chrome writes your PDF, then refuses to exit\n\n \n
← all demos

Headless Chrome writes your PDF, then refuses to exit

August 2026 · on a hang that quietly truncates files

Rendering HTML to PDF from a script looks like it should be one line:

chrome --headless --print-to-pdf=out.pdf page.html

It works. The file appears. And then the command sits there forever. Your build hangs, your CI job times out, and the obvious fix quietly corrupts your output.

The obvious fix is the bug

Everyone reaches for the same thing:

timeout 30 chrome --headless --print-to-pdf=out.pdf page.html

This is worse than the hang, because it fails silently. Chrome writes the PDF incrementally. If the timer fires while bytes are still going out, you get a file that exists, has a plausible size, and opens in most viewers — missing its last pages. Nothing errors. Nothing warns you. You find out when a client asks where page 9 went.

Why a fixed wait can't work. Too short truncates. Too long wastes the difference on every single render. And the right number is not a property of your script — it depends on the page, the fonts, the machine, and what else is running.

Wait for the file, not the clock

The reliable signal is not "has enough time passed" but "has the file stopped growing". Poll the size; when it holds steady across consecutive checks and the file is non-empty, the write is done and you can kill the process:

def run_chrome(chrome, args, out, timeout=60, settle=3):
    """Chrome headless does not exit after writing output.
       Size-stability is the reliable signal."""
    proc = subprocess.Popen([chrome, *args],
                            stdout=subprocess.DEVNULL,
                            stderr=subprocess.DEVNULL)
    stable, last, deadline = 0, -1, time.time() + timeout
    while time.time() < deadline:
        if proc.poll() is not None:      # exited on its own
            break
        size = os.path.getsize(out) if os.path.exists(out) else 0
        stable = stable + 1 if size == last and size > 0 else 0
        if stable >= settle:             # steady across N polls
            break
        last = size
        time.sleep(0.4)
    proc.terminate()
    try: proc.wait(timeout=5)
    except subprocess.TimeoutExpired: proc.kill()

Three consecutive stable polls at 0.4 s is a good default: fast pages finish in about a second, and a slow one takes as long as it takes without a magic number in your build script.

Two more that will catch you

Give every run its own profile directory. Without one, a second invocation finds the first Chrome's lock, attaches to that instance instead of starting a new one, and returns immediately — having rendered nothing. Your script reports success and the output file is whatever the previous run left behind:

--user-data-dir=$(mktemp -d)

Animations render mid-flight. A page with a CSS transition or a chart that animates in will be captured wherever it happened to be. Freeze it:

--virtual-time-budget=2000 --force-prefers-reduced-motion

And don't trust the page count

Having fixed the truncation, I wanted to assert the page count in CI. The obvious approach reads the first /Count in the PDF and compares it. Mine reported 8 pages for an 11-page document, and I nearly "fixed" a document that was already correct.

PDF page trees can nest. The first /Count you encounter may describe a subtree, not the document. Count the leaves instead:

pages = data.count(b'/Type /Page') - data.count(b'/Type /Pages')
The general lesson, and the one that cost me the most: a verification step that is subtly wrong is worse than no verification, because it spends your trust. Both bugs here produce confident, plausible, wrong answers rather than errors.

All of it, packaged

The watchdog, the profile isolation, the motion freezing and exact-size PNG rendering are in a single-file tool with no dependencies beyond a Chrome you already have.

htmlshot

Render HTML to exact-size PNGs and PDFs from the command line. One Python file, MIT.