Guides · debugging

Your PDF header is not missing — five reasons Chromium put it where you cannot see it

Published 2026-08-17 · every number below was measured on the day of writing with Chromium 151.0.7922.34, driven by Playwright 1.62.1 and by puppeteer-core 25.8.0 against the same browser binary. The command lines are in the article so you can repeat them.

You passed headerTemplate. A PDF came out. It renders, it has the right number of pages, and there is no header on it:

const bytes = await page.pdf({
  format: 'A4',
  headerTemplate: '<div class="brandbar">Acme Ltd — invoice</div>',
  footerTemplate: '<div class="brandbar">page <span class="pageNumber"></span></div>',
});

// A PDF comes out. It renders. There is no header on it.

Four of the five causes below produce exactly that symptom — a valid PDF with nothing visible at the top — and they are distinguishable in about a minute if you know what to look for. The fifth is the interesting one: the header is on the page, in the text layer, at a glyph height of 0.75 pt. You can select it with the mouse. You cannot read it.

The sixty-second version

SymptomCauseFix
Nothing at the top, nothing in the text layer displayHeaderFooter defaults to false and you passed a template without itdisplayHeaderFooter: true
Header is there but sits on top of the first line of body text margin defaults to zero, so the band and the content share the same strip of paperSet margin.top bigger than the header
Text is selectable but microscopic The default font size inside the template is 1 px Set font-size in the template, always
Your CSS class does nothing The page's stylesheet is not visible inside the template Put a <style> block inside the template
Text appears, the logo does not The template fetches no external resources and runs no scripts Inline the image as a data: URI

The rest of this page is the evidence, because four of those five are not in either library's documentation and the fifth is one sentence that is easy to read past.

How this was measured

Everything here is geometry read back out of the finished PDF, not a screenshot judged by eye. One two-page document, one option changed at a time, and three command-line readers:

# one two-page document with unique anchor words in the body and the template
#   body:     <h1>BODYTOP</h1>
#   header:   <div ...>HDRTEXT</div>
#
# vary ONE option, then read the geometry back out of the PDF:

pdftotext -bbox out.pdf -   # per-word boxes, in points
#   <word xMin="0.00" yMin="16.50" xMax="41.46" yMax="25.50">HDRTEXT</word>
#          ^ x position     ^ y from the top   ^ so the glyph box is 9.00pt tall

pdfimages -list out.pdf     # did the header's <img> actually get embedded?
pdftoppm  -r 72 out.pdf r   # did any ink land in the header band at all?

The same option sets were then run through puppeteer-core pointed at the Chromium that Playwright had already downloaded — chromium.executablePath() — so the engine is a constant and the driver is the variable. The two agreed on every case; the table is near the bottom.

Cause 1 — the option that turns the band on is off by default

Both libraries document it. Playwright: displayHeaderFooter, "Defaults to false." Puppeteer's PDFOptions: "Whether to show the header and footer", default false. Passing headerTemplate does not imply it, and neither library warns you that you passed a template it is going to ignore.

Measured: with the templates supplied and displayHeaderFooter left out, the word HDRTEXT is not in the PDF's text layer at all. This is the easy one, and it is still the most common one.

A useful diagnostic before you read any further: set displayHeaderFooter: true and pass no templates. Chromium prints its own, and it is perfectly legible — we measured 8/17/26, 8:51 AM · Header probe · about:blank · 1/2. If the built-in one shows and yours does not, your problem is one of causes 3–5, not cause 1 or 2.

Cause 2 — the header is drawn in the margin, and the margin is zero

The header and footer live in the page margins. Both libraries default margin to zero — Playwright's reference says the defaults are 0, Puppeteer's says "no margins are set". So the band is rendered into a strip of paper that your body content is also using.

It does not clip, and it does not push the content down. It overlaps:

displayHeaderFooter: true, headerTemplate: '<div style="font-size:12px">HDRTEXT</div>'

  margin option                header glyph y     body glyph y     result
  ---------------------------------------------------------------------------
  (none passed)                16.5pt             19.8pt           OVERLAP
  { top: '0' }                 16.5pt             19.8pt           OVERLAP
  { top: '10mm' }              16.5pt             47.6pt           clear
  { top: '20mm' }              16.5pt             76.1pt           clear

The header never moves. The body does.

Read the first two rows: the header glyph sits at y = 16.5 pt from the top of the page and the body's first heading starts at 19.8 pt — inside the header's own 9 pt glyph box. On a document with a white background and dark text at the top of page one, the two overlap into an unreadable smudge that people describe as "the header did not work". On a document whose first element is an image or a filled panel, the header is simply painted over and genuinely invisible.

Rule of thumb: margin.top has to be larger than the header's rendered height, and margin.bottom larger than the footer's. A 10 pt header in a 10 mm (28.3 pt) margin is comfortable; the same header in a 6 mm margin is not.

The band ignores your left and right margins

This one surprised us. Setting margin.left indents the body and does nothing to the header — the band spans the full paper width, edge to edge:

margin: { top:'25mm', bottom:'25mm' }              header x = 0.0    body x =  0.0
margin: { top:'25mm', bottom:'25mm',
          left:'20mm', right:'20mm' }              header x = 0.0    body x = 56.2

So a header that looks correctly aligned in the browser will be flush against the paper edge in the PDF, and most printers will clip the first few millimetres of it. If you want the header to line up with the body, put the same indent inside the template — padding: 0 15mm with box-sizing: border-box — rather than expecting margin.left to do it.

Cause 3 — the default font size in the template is one pixel

This is the one that costs people an afternoon, because the header is there. It is in the text layer, pdftotext prints it, you can select it in a viewer. It is 0.75 pt tall.

We pinned the default by bisection — rendering the same word at a series of explicit sizes until one matched the unstyled output exactly:

headerTemplate                          glyph box for the word HDRTEXT
------------------------------------------------------------------------
<div>HDRTEXT</div>                       3.45 x 0.75 pt      <-- default
<div style="font-size:1px">              3.45 x 0.75 pt      <-- identical
<div style="font-size:2px">              6.91 x 1.50 pt
<div style="font-size:4px">             13.82 x 3.00 pt
<div style="font-size:8px">             27.64 x 6.00 pt
<div style="font-size:12px">            41.46 x 9.00 pt

Two things fall out of that table. First, the relationship is exactly 0.75 pt of glyph box per CSS pixel throughout, which is the ordinary 96 dpi-to-72 dpi conversion — so nothing exotic is happening to the type. Second, the unstyled template and font-size:1px produce byte-identical geometry: 1 px is the default font size inside a header or footer template, and 1 px of Helvetica is not something a person can read on paper.

Neither library's reference states this default, and we did not find it stated in the option documentation of either. Treat "set an explicit font-size in every header and footer template" as unconditional — there is no size you inherit that is worth having.

Cause 4 — your stylesheet does not reach the template, but a <style> block does

Playwright's reference states it in one line that is easy to read past: "Page styles are not visible inside templates." Puppeteer's PDFOptions page, read on the same day, lists the same five injectable classes without that remark.

What the sentence does not tell you is where styling is allowed, and the answer is more generous than most workarounds assume. A <style> block inside the template itself works fine — you are not restricted to inline attributes:

where the rule lives                                 glyph height
---------------------------------------------------------------------
style="font-size:18px"        (inline attribute)      13.5 pt   works
<style>.k{font-size:18px}</style>  (in the template)  13.5 pt   works
<style>div{font-size:18px}</style> (in the template)  13.5 pt   works
.brandbar{font-size:22px}     (in the PAGE's <style>)  0.75 pt  ignored
no rule anywhere                                       0.75 pt

So the failure is specific: the template is rendered as its own little document, and it cannot see the CSS belonging to the page you are printing. Anything the template brings with it — an inline attribute, a <style> block, a selector matching its own markup — is honoured normally.

Note what rows four and five of that table have in common: 0.75 pt. Using a class from the page's stylesheet is not a separate bug from cause 3; it is how most people arrive at cause 3. The class matches nothing, so the type falls back to the 1 px default. That is why "my header vanished when I moved the styles into the stylesheet" is such a common way to describe it.

Cause 5 — the template loads nothing and runs nothing

Playwright's other documented limitation is "Script tags inside templates are not evaluated", which we confirmed: a template whose script rewrites its own text still prints the placeholder. Less documented is what happens to images.

<img src="…"> inside headerTemplate        image XObjects in the PDF
---------------------------------------------------------------------
no image at all (baseline)                  0
data:image/png;base64,…                     embedded
https://example.com/logo.png                0
/logo.png                                   0

control: the same https URL in the page BODY, waitUntil 'networkidle'
                                            embedded

<script>document.getElementById('h').textContent='SCRIPTRAN'</script>
  -> the PDF still says PLACEHOLDER.

An <img> with an absolute https URL contributed no image object to the PDF. The control matters here, so we ran it: the same URL placed in the page's own body, with waitUntil: 'networkidle', embedded normally. The network is reachable; the header context did not use it. A relative URL has no chance either — the template has no base URL to resolve against.

The workaround is to inline the image as a data: URI, which does embed. For a small monochrome logo that is a few kilobytes of base64 per document. For anything larger, consider putting the branding in the page body's first block instead and using the band for text and page numbers, which is what it is good at.

The five classes that do work

Both libraries document the same five, and all five substituted correctly in our run: date, title, url, pageNumber, totalPages. A span carrying an unrecognised class renders empty rather than raising an error — so a typo in pageNumber is silent, which is worth knowing when a footer shows "Page of ".

Two extras: background colour, and what printBackground does not cover

printBackground is the option people reach for when a coloured header bar does not print. It does not apply to the band. We rendered a header with a solid black background and red text, and counted non-white pixels in the top 50 pt of the raster:

headerTemplate: '<div style="font-size:14px;background:#000;color:#f00;
                  height:30px;width:100%">HDRTEXT</div>'

                                          non-white pixels in the top 50pt
  printBackground: false                                214   (text only)
  printBackground: true                                 214   (text only)
  + print-color-adjust: exact in the template        13,112   (bar painted)

color: on the text was honoured in all three.

214 non-white pixels is the text alone — the same count with printBackground off and on. The bar appears when the template carries print-color-adjust: exact (with the -webkit- prefix alongside it for older Chromium builds), at which point the pixel count goes up by a factor of sixty. Text color, by contrast, was honoured in every case — which is why this one is confusing: half your styling works.

A header that works

All five causes addressed at once, with a comment on each:

const header = `
  <style>
    /* lives INSIDE the template, because the page's CSS does not reach here */
    .bar { font-size: 10px; font-family: Helvetica, Arial, sans-serif;
           width: 100%; padding: 0 15mm; box-sizing: border-box;
           display: flex; justify-content: space-between;
           color: #333; }
    /* only needed if you want a background colour to print: */
    .bar { background: #f2f2f2; -webkit-print-color-adjust: exact;
           print-color-adjust: exact; }
  </style>
  <div class="bar">
    <span>Acme Ltd — invoice</span>
    <img src="data:image/png;base64,iVBORw0…" height="12">
  </div>`;

const footer = `
  <style>.bar{font-size:9px;width:100%;padding:0 15mm;
              box-sizing:border-box;text-align:right;color:#666}</style>
  <div class="bar">Page <span class="pageNumber"></span>
       of <span class="totalPages"></span></div>`;

const bytes = await page.pdf({
  format: 'A4',
  displayHeaderFooter: true,          // 1. off by default
  headerTemplate: header,
  footerTemplate: footer,
  margin: { top: '22mm', bottom: '18mm',   // 2. or the header lands on your text
            left: '15mm', right: '15mm' },
  printBackground: true,              // for the page body; not for the band
});

Two details in there that are easy to miss. The footer gets its own <style> block, because the header's does not carry over — each template is a separate document. And printBackground: true is set for the body of the page; the band's own background is handled by print-color-adjust inside the template, as above.

Puppeteer and Playwright behave identically here

Both libraries end up at the same Chromium DevTools Protocol call — Puppeteer's CDP page implementation sends Page.printToPDF in lib/puppeteer/cdp/Page.js, and the same command name is present in Playwright's bundled browser-server code. So agreement is expected; it is still worth measuring rather than assuming, because the two references describe the behaviour differently. Same six cases, same browser binary, both drivers:

                                    Playwright 1.62.1      puppeteer-core 25.8.0
  ------------------------------------------------------------------------------
  displayHeaderFooter omitted       absent                 absent
  +displayHeaderFooter, no margin   9pt @ y=16.5 OVERLAP   9pt @ y=16.5 OVERLAP
  +margin 25mm                      9pt @ y=16.5 clear     9pt @ y=16.5 clear
  template with no font-size        0.75pt                 0.75pt
  template uses a page class        0.75pt                 0.75pt
  <style> block inside template     13.5pt                 13.5pt

Which means: a fix found in one transfers to the other, and a Puppeteer answer you found on a forum applies to your Playwright code. It also means neither library can fix any of this on its own — the behaviour belongs to Chromium's print pipeline, and the libraries are passing your template through to it.

If you would rather not own this

Everything above is a fixed cost you pay once and then encode in a helper. Plenty of teams should just do that; a header helper is forty lines and it stops being interesting.

The alternative is handing the rendering to a service. snapdok.io is one such option — it runs the same Chromium via Playwright, and it takes pdf_header and pdf_footer as HTML strings that are handed to the same print pipeline described above:

curl -X POST https://snapdok.io/v1/render \
  -H "Authorization: Bearer $SNAPDOK_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://app.example.com/invoice/42",
       "format":"pdf",
       "pdf_header":"<div style=\"font-size:10px;width:100%;text-align:center\">Invoice 42</div>",
       "pdf_footer":"<div style=\"font-size:9px;width:100%;text-align:right\">Page 1</div>"}' \
  -o invoice.pdf
What that does and does not solve, stated plainly. It solves cause 2 and nothing else: when a header or footer is present, the Snapdok API reserves a 60 px margin for it automatically, so the band cannot land on your content. Causes 3, 4 and 5 are Chromium's and they reach you unchanged — you still set an explicit font-size, you still style inside the template, you still inline your logo as a data: URI. And the costs are real: paper size is A4 and not configurable today, there is no orientation or scale option, and your HTML leaves your network, which is a deliberate decision for documents assembled from customer data. If you need page geometry control, Playwright and WeasyPrint both give you more of it than we do — see the flag map for what each one exposes.

The short version

A missing PDF header is almost never a missing PDF header. Set displayHeaderFooter: true, give margin.top more room than the header needs, put an explicit font-size in the template because the inherited one is 1 px, keep the CSS inside the template because the page's stylesheet cannot be seen from there, and inline any image as a data: URI because the template will not fetch it.

And when it still looks wrong, read the geometry instead of the render. pdftotext -bbox takes a second and tells you whether the text is absent, or present and 0.75 pt tall, or present and underneath your first paragraph. Those are three different bugs with three different fixes, and they look the same on screen.

Related, from the same measurements: every wkhtmltopdf flag and its 2026 equivalent, which covers the five page.pdf() defaults that differ from wkhtmltopdf's, and the migration guide for the wider decision.

Sources, both read 2026-08-17: Playwright's page.pdf() reference — the defaults for displayHeaderFooter and margin, the five injectable classes, and the two template limitations quoted above · Puppeteer's PDFOptions reference — the same defaults and the same five classes. Every measurement is ours, taken on 2026-08-17 with Chromium 151.0.7922.34 via Playwright 1.62.1 and via puppeteer-core 25.8.0 launched against the same binary, and reproducible with the three command lines given near the top. Where our reading of the two references differs from what we measured, the measurement is reported and the reference is quoted, so you can check both.