How to Set Page Numbers and Run JavaScript in Chrome printToPDF Headers and Footers
Add page numbers with Chrome printToPDF placeholders, and run custom JavaScript before generating reliable PDFs.

Chrome’s DevTools Protocol supports page numbers in Page.printToPDF headers and footers through documented placeholder classes. Enable displayHeaderFooter, then put pageNumber and totalPages spans in a valid HTML template:
<footer>
<span class="pageNumber"></span> / <span class="totalPages"></span>
</footer>
Header and footer templates are HTML with value substitution. They are not documented as a JavaScript execution context. If you need custom logic, run JavaScript in the page with a Page or Runtime protocol command before calling Page.printToPDF. Treat page scripting and template substitution as two separate steps. See the Chrome DevTools Protocol Page.printToPDF reference for the current parameter definitions.
What printToPDF does with headers and footers
displayHeaderFooter is false by default. When it is enabled, Chrome renders the supplied headerTemplate and footerTemplate on each printed page. The templates must be valid HTML fragments.

Chrome substitutes these documented classes:
| Class | Value | Typical use |
|---|---|---|
pageNumber |
Current printed page | “Page 3” |
totalPages |
Total number of printed pages | “of 12” |
date |
Print date | Generated date |
title |
Document title | Report title |
url |
Document URL | Source attribution |
For example:
<header style="font-size:9px; width:100%; text-align:center;">
<span class="title"></span>
</header>
<footer style="font-size:9px; width:100%; text-align:center;">
Page <span class="pageNumber"></span>
of <span class="totalPages"></span>
</footer>
Use CSS in the template for alignment and font sizing. Reserve enough top and bottom margin for the rendered header and footer; otherwise content can appear crowded or overlap the printed regions.
Complete Node.js example with page numbers
The following script uses Puppeteer to launch Chromium, load a document, wait for it to settle, and call the underlying CDP command. It demonstrates the important ordering: page JavaScript runs first, then PDF generation substitutes the header and footer values.
import puppeteer from 'puppeteer';
import { writeFile } from 'node:fs/promises';
const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();
await page.goto('https://example.com/report', {
waitUntil: 'networkidle2'
});
// Page JavaScript runs in the normal page context before printing.
await page.evaluate(() => {
document.documentElement.classList.add('print-ready');
const generated = document.querySelector('[data-generated-at]');
if (generated) generated.textContent = new Date().toISOString();
});
await page.emulateMediaType('print');
const client = await page.target().createCDPSession();
const result = await client.send('Page.printToPDF', {
displayHeaderFooter: true,
headerTemplate: `
<div style="font-size:9px; width:100%; text-align:center;">
<span class="title"></span>
</div>`,
footerTemplate: `
<div style="font-size:9px; width:100%; text-align:center;">
Page <span class="pageNumber"></span>
of <span class="totalPages"></span>
</div>`,
paperWidth: 8.5,
paperHeight: 11,
marginTop: 0.65,
marginBottom: 0.65,
marginLeft: 0.6,
marginRight: 0.6,
printBackground: true,
preferCSSPageSize: true,
scale: 1
});
await writeFile('report.pdf', Buffer.from(result.data, 'base64'));
await browser.close();
Install the dependency with npm install puppeteer. If your application already manages a Chrome process, connect Puppeteer to that process instead of launching a second browser.
Running JavaScript before printing
There are three common ways to execute JavaScript through the protocol. Choose based on when the code must run and which JavaScript world it needs.
1. Evaluate after navigation
Use Runtime.evaluate or Puppeteer’s page.evaluate after navigation when the document is loaded and your code should see the page’s normal JavaScript context.
const client = await page.target().createCDPSession();
await client.send('Runtime.evaluate', {
expression: `
document.querySelectorAll('[data-print-hide]').forEach(el => {
el.style.display = 'none';
});
`,
awaitPromise: true,
returnByValue: true
});
awaitPromise: true matters when the expression returns a promise. Wait for the resulting DOM change before printing.
2. Inject code into every new document
Page.addScriptToEvaluateOnNewDocument runs code early, before the page’s own scripts execute. This is useful for setting a flag, replacing a browser API, or preparing state consistently across navigations.
await client.send('Page.addScriptToEvaluateOnNewDocument', {
source: `
window.__PDF_MODE__ = true;
document.addEventListener('DOMContentLoaded', () => {
document.documentElement.dataset.pdfMode = 'true';
});
`
});
Register this before calling page.goto. Remove the registration when it is no longer needed if the browser session is reused.
3. Use an isolated world
Page.createIsolatedWorld provides a separate execution world. It prevents helper variables from colliding with page scripts, but changes how objects and page-defined globals are accessed. Use it for instrumentation or automation helpers that should not modify the page’s main JavaScript namespace.
The protocol overview explains that tip-of-tree definitions change frequently, while stable protocol versions expose a smaller compatibility surface. Match the Chrome version in production to the protocol version your code expects, and verify commands against the browser build you deploy. See the DevTools Protocol overview.
Layout options that affect page numbers
Page numbering is calculated after pagination. Changing any of these settings can change both the current page and the total page count:
| Option | Effect | Practical guidance |
|---|---|---|
paperWidth, paperHeight |
Paper dimensions in inches | Set explicitly for repeatable output. |
marginTop, marginBottom |
Space around page content | Leave room for header and footer. |
marginLeft, marginRight |
Horizontal printable space | Keep templates inside these bounds. |
landscape |
Swaps page orientation | Useful for wide tables. |
scale |
Scales printed content | Changing it can alter pagination. |
printBackground |
Includes background colors and images | Enable when visual styling is part of the report. |
preferCSSPageSize |
Prefers CSS @page dimensions |
Use when the document owns its print size. |
pageRanges |
Prints selected pages | Page numbers reflect the generated PDF’s page sequence. |
The protocol’s defaults include 8.5 by 11 inch paper, 1 cm margins, and scale 1. Dimensions and margins are expressed in inches in the protocol. Explicit values make deployments easier to compare.
CSS and template patterns
Keep headers and footers readable
<style>
@page { margin: 18mm 14mm 20mm; }
@media print {
.screen-only { display: none !important; }
.avoid-break { break-inside: avoid; }
}
</style>
When preferCSSPageSize is enabled, CSS page rules can control the sheet size. Test the interaction between CSS margins and protocol margins because both influence available content space.
Use a stable document title
The title placeholder comes from the document. Set document.title before printing if the title is generated dynamically:
await page.evaluate(() => {
document.title = 'Quarterly revenue report';
});
Hide controls before capture
Use a print stylesheet for predictable results. JavaScript is useful when the elements to hide depend on runtime state, but CSS is simpler for a fixed print design.
What JavaScript cannot do in a template
Do not assume that adding a <script> element to headerTemplate or footerTemplate will execute. The documented contract describes HTML markup plus special classes for injected print values. It does not document template script execution.
This pattern is therefore unsafe as a solution:
<footer>
<script>document.body.dataset.page = 'x';</script>
</footer>
Instead, calculate or inject values in the page before calling printToPDF. For a value that must differ by page, use a document layout mechanism such as generated content or separate page sections; the template itself only receives the documented print substitutions.
Using the raw CDP command
If you already have a WebSocket connection to Chrome, send a command like this:
{
"id": 42,
"method": "Page.printToPDF",
"params": {
"displayHeaderFooter": true,
"headerTemplate": "<div><span class=\"title\"></span></div>",
"footerTemplate": "<div>Page <span class=\"pageNumber\"></span> of <span class=\"totalPages\"></span></div>",
"printBackground": true,
"marginTop": 0.7,
"marginBottom": 0.7
}
}
The response contains PDF data, commonly as a base64 string. Decode it as bytes and write it to a file. Some clients support a stream return mode; follow the protocol schema exposed by your Chrome version.
“Or skip the browser setup” with ScreenshotNeo
If your application needs a PDF from a URL rather than direct CDP control, ScreenshotNeo provides a one-request screenshot and PDF API. Its PDF options include paper size, margins, landscape mode, and page ranges. It also supports custom JavaScript, custom CSS, waiting for selectors or network idle, cookies, headers, user agents, timezone, geolocation, and bulk capture.

See the ScreenshotNeo API documentation for request options.
cURL
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://stripe.com \
-o shot.webp
Python
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
Node.js
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = Buffer.from(await res.arrayBuffer());
ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The Free plan includes 1,000 shots per month without a card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| No header or footer | displayHeaderFooter remains false. |
Set it to true and confirm the command reaches the intended page. |
| Page numbers show as blank | Missing documented class or malformed template HTML. | Use <span class="pageNumber"></span> and valid markup. |
| Footer overlaps content | Bottom margin is too small. | Increase marginBottom and test the actual font size. |
| Total pages changes unexpectedly | Late fonts, images, JavaScript, scale, or CSS page rules change layout. | Wait for required resources, then print; keep scale and page dimensions explicit. |
| Dynamic content is missing | Printing starts before the page finishes rendering. | Wait for a selector, a known application signal, or network idle before evaluation and printing. |
| Script has no effect | It ran in the wrong context or before the target DOM existed. | Use Runtime.evaluate after navigation, or inject with addScriptToEvaluateOnNewDocument when early execution is required. |
| PDF differs between environments | Different Chrome builds, fonts, viewport, or protocol versions. | Pin the browser image, install required fonts, set viewport and print options, and validate the deployed protocol. |
| Large PDFs time out | Heavy pages, slow resources, or unbounded scripts. | Block unnecessary resources, wait on a specific readiness signal, and enforce an application timeout. |
Performance, reliability, and cost considerations
PDF generation is sensitive to page weight and layout complexity. Reusing a browser process avoids startup cost, while isolating jobs into separate pages prevents state from leaking between requests. Close pages after each job and cap concurrent printing to the capacity of the host.
Wait for the condition that represents usable content instead of sleeping for an arbitrary duration. A selector or application readiness flag is usually more reliable than a fixed delay. Fonts and images can change line wrapping, so include them in your readiness condition when pagination must be stable.
Cache deterministic inputs when appropriate. For reports containing timestamps, random identifiers, or user-specific data, disable or vary caching. With ScreenshotNeo, you can choose a cache TTL and inspect billing and verdict headers; cache hits are not billed.
For production reliability, record the URL, Chrome version, print options, execution duration, output size, and failure reason. Retry navigation failures with a limit, but do not blindly retry a page that consistently returns a bot challenge or blank document. ScreenshotNeo identifies bot checks, blank pages, timeouts, and failed loads so callers can handle them without treating them as successful documents.
Checklist for production PDFs
- Enable
displayHeaderFooter. - Use documented placeholder classes for page values.
- Run custom JavaScript through Page or Runtime commands before printing.
- Wait for content, fonts, and images that affect pagination.
- Set paper dimensions, margins, scale, orientation, and background behavior explicitly.
- Reserve header and footer space.
- Pin and validate the Chrome build used in production.
- Inspect output bytes and failure responses before publishing the PDF.
- Record the protocol and browser versions for reproducibility.
FAQ
Can I display “Page X of Y”?
Yes. Put pageNumber and totalPages spans in the footer template with displayHeaderFooter: true.
Can a footer template read my page’s DOM?
Do not rely on that. Templates are documented for HTML and print-value substitution. Read or modify the DOM before printing with a Page or Runtime command.
Can I use a custom page count calculated in JavaScript?
You can calculate page-related content in the document before printing, but the protocol’s reliable built-in count is the totalPages placeholder. Per-page custom values require a document layout strategy rather than template scripting.
Why did changing a margin alter the page count?
Margins change the printable area, which changes line wrapping and page breaks. Any pagination-affecting option can change both values.
Which protocol version should I target?
Target the protocol exposed by the Chrome build you deploy. Tip-of-tree definitions change frequently, while stable protocol versions provide a narrower compatibility surface.


