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
| Symptom | Cause | Fix |
|---|---|---|
| Nothing at the top, nothing in the text layer | displayHeaderFooter defaults to false and you passed a
template without it | displayHeaderFooter: 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 paper | Set 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.
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.
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
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.