How to Fix Unwanted Patterns in PDFs Generated From Dynamic HTML in Node.js
Fix repeating backgrounds, missing colors, broken page breaks, and dynamic-content glitches in Node.js PDFs with Puppeteer or Playwright.
Unwanted patterns in a PDF usually come from four inputs interacting: print CSS, background painting, page geometry, and content that was captured before it finished rendering. Make those inputs explicit, then generate the PDF from a fixed browser version and fixed dimensions.
Quick fix checklist
- Decide whether the PDF should use print styles or screen styles.
- Set
printBackground: truewhen backgrounds or colors matter. - Add
-webkit-print-color-adjust: exactfor designs that require exact colors. - Define
@pagesize and margins, then usepreferCSSPageSize: truewhen CSS should control geometry. - Wait for application data, images, stylesheets, and fonts before calling
page.pdf(). - Use
break-inside,break-before, andbreak-afterto control page boundaries. - Keep viewport, paper size, margins, and scale fixed while diagnosing the output.
Why dynamic HTML looks different in a PDF
Browser PDF APIs render with the print CSS media type by default. Puppeteer describes page.pdf() as generating a PDF “with the print CSS media type.” Puppeteer’s API documentation and Playwright’s media emulation documentation describe the controls available when you need screen styling instead.
| Symptom | Likely cause | First fix |
|---|---|---|
| Backgrounds or gradients disappear | Print backgrounds are disabled | Set printBackground: true |
| Colors are muted or altered | Print color adjustment is changing them | Add -webkit-print-color-adjust: exact |
| Sections repeat at page boundaries | Fixed elements, competing geometry, or a split inside a component | Fix @page, margins, and break rules |
| Text wraps differently | Paper width, scale, viewport, or font readiness changed | Freeze dimensions and wait for fonts |
| Cards contain old data | PDF was generated before client-side rendering completed | Wait for a readiness selector or application signal |
Use an intentional print stylesheet
Put the PDF baseline in CSS rather than relying on incidental screen styles. Define the paper geometry and color behavior explicitly:
@page {
size: A4;
margin: 16mm 14mm 18mm;
}
@media print {
* {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
body {
background: #fff;
color: #111;
}
.card,
.chart,
table,
figure {
break-inside: avoid;
}
.chapter,
.appendix {
break-before: page;
}
.screen-only {
display: none !important;
}
}
Use the color-adjust declarations only when exact colors are required. They can increase ink coverage and may make a document less suitable for economical printing.
Complete Puppeteer example
The following script waits for navigation, an application readiness marker, images, and fonts before generating the PDF. It also makes print backgrounds and CSS page size explicit.
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
await page.goto('https://example.com/report', {
waitUntil: 'networkidle0',
timeout: 60000
});
// Replace this selector with an element your application adds when data is ready.
await page.waitForSelector('[data-report-ready="true"]', { timeout: 30000 });
await page.evaluate(async () => {
const images = Array.from(document.images);
await Promise.all(images.map((img) => {
if (img.complete) return Promise.resolve();
return new Promise((resolve) => {
img.addEventListener('load', resolve, { once: true });
img.addEventListener('error', resolve, { once: true });
});
}));
if (document.fonts) await document.fonts.ready;
});
await page.pdf({
path: 'report.pdf',
printBackground: true,
preferCSSPageSize: true,
displayHeaderFooter: false,
scale: 1,
margin: { top: '0', right: '0', bottom: '0', left: '0' }
});
} finally {
await browser.close();
}
})();
Puppeteer’s PDF guide documents the browser launch, navigation, PDF generation, and close lifecycle. Its API waits for fonts by default, but your application data still needs an explicit readiness strategy.
Choose print media or screen media deliberately
Keep the default print media when you have a dedicated @media print design. If the page was designed only for the screen, emulate screen media before generating the PDF:
await page.emulateMediaType('screen');
await page.pdf({
path: 'screen-styled.pdf',
printBackground: true,
preferCSSPageSize: true
});
Do not switch media while debugging another variable. First capture a stable print or screen result, then compare the other mode.
Control page size, margins, and scaling
CSS and API options can compete. During diagnosis, choose one source of truth.
- CSS controls geometry: define
@pageand setpreferCSSPageSize: true. - API controls geometry: use
format,width,height,margin, andscale, and remove conflicting@pagerules.
A small width change can alter line wrapping, which changes block heights and moves every later page break. A scale change can create apparent repetition because fixed or sticky elements occupy a different number of printed pixels.
Keep dynamic content and assets stable
networkidle0 is useful for pages that become quiet, but it is not proof that application data is complete. Prefer a deterministic signal from your application, such as data-report-ready="true", after API data has been inserted and charts have finished drawing.
- Wait for a selector that only appears after the final render.
- Wait for a known delay when a third-party widget has no readiness event.
- Wait for network idle only when long polling and analytics do not keep the page busy.
- Resolve image load and error events so one broken image cannot leave the script waiting forever.
- Wait for
document.fonts.ready; verify that the requested font files are actually reachable.
Prevent unwanted page breaks and repeated patterns
.invoice-line,
.summary-card,
.chart-container {
break-inside: avoid;
page-break-inside: avoid;
}
.new-section {
break-before: page;
page-break-before: always;
}
.end-section {
break-after: page;
page-break-after: always;
}
Inspect every page boundary after adding these rules. An oversized element cannot fit in the remaining space even with break-inside: avoid, so the browser must move it or split it. Tables, flex layouts, and absolutely positioned elements deserve special attention.
Playwright equivalent
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
await page.goto('https://example.com/report', { waitUntil: 'networkidle', timeout: 60000 });
await page.waitForSelector('[data-report-ready="true"]');
await page.evaluate(() => document.fonts ? document.fonts.ready : Promise.resolve());
await page.pdf({
path: 'report.pdf',
printBackground: true,
preferCSSPageSize: true
});
} finally {
await browser.close();
}
})();
Compare Puppeteer and Playwright only after HTML, CSS, browser version, viewport, paper settings, and readiness timing are identical. Their media controls, PDF option surfaces, and browser lifecycle APIs differ.
Diagnostic workflow
- Pin the browser version, viewport, paper format, margins, and scale.
- Record whether print or screen media is active.
- Enable
printBackgroundand inspect color-adjust rules. - Use either CSS page size or API geometry while isolating the problem.
- Log readiness milestones: data inserted, images settled, fonts ready, PDF started.
- Temporarily outline boxes with
* { outline: 1px solid rgba(255,0,0,.15) }to find the element that repeats or splits. - Remove fixed headers, sticky elements, animations, and transitions one at a time.
- Inspect the generated PDF at 100% zoom and check each page boundary.
Troubleshooting common errors
| Error or symptom | Cause | Fix |
|---|---|---|
| “Backgrounds are missing” | Print backgrounds are off | Set printBackground: true; check that the print stylesheet does not override the background. |
| “The PDF uses the wrong layout” | Print media is active while the design expects screen media | Call page.emulateMediaType('screen') or create an intentional @media print stylesheet. |
| “The same header appears inside content” | A fixed or sticky element is being painted on every page | Disable it for print, or implement a deliberate print header with supported page-margin techniques. |
| “Cards split in half” | No break rule, or the card is taller than a page | Add break-inside: avoid; redesign elements that cannot fit on one page. |
| “Fonts change or text wraps” | Font files were unavailable or not ready | Check network responses, preload fonts, and wait for document.fonts.ready. |
| “Charts are blank” | Canvas or SVG rendering was still in progress | Wait for the chart library’s completion event or a DOM readiness marker. |
| “Navigation times out” | Long polling, blocked third-party requests, or a slow origin | Use a larger timeout, wait for a specific selector, and remove nonessential requests. |
| “Only some pages differ between runs” | Unstable data, time, random IDs, animations, or responsive width | Freeze inputs, disable animation, set timezone and viewport, and use deterministic test data. |
Performance, reliability, and cost
- Reuse a browser process when generating many PDFs, but create a fresh page per job and close pages in a
finallyblock. - Set explicit navigation and readiness timeouts so hung origins do not consume workers indefinitely.
- Block analytics, ads, and video requests only when they are not part of the document; blocking a required stylesheet or font creates a different PDF.
- Keep the viewport and paper settings constant to make cache keys, visual diffs, and incident reproduction reliable.
- Capture PDFs from a pinned Chromium version. Browser upgrades can change pagination and font metrics.
- For large documents, reduce unnecessary DOM nodes and images, and monitor memory per page.
- PDF generation consumes your own CPU, memory, browser processes, and bandwidth when self-hosted; a hosted API shifts that operational work to the provider.
Or skip the browser setup
ScreenshotNeo provides a website capture API and MCP server. It accepts one GET request and can return PNG, JPEG, WebP, or PDF output. Cookie and consent banners, newsletter popups, and chat widgets are removed before capture. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing result.
See the ScreenshotNeo API documentation for PDF options and the full set of capture controls.
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://example.com/report \
-o report.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://example.com/report"},
timeout=90,
)
r.raise_for_status()
open("report.webp", "wb").write(r.content)
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://example.com/report'
});
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());
require('fs').writeFileSync('report.webp', body);
ScreenshotNeo also offers an MCP server with take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.
FAQ
Should I use preferCSSPageSize?
Use it when your @page declaration is the intended source of paper size and margins. Leave it off while debugging if API settings are meant to control geometry.
Does waiting for network idle guarantee correct data?
No. Client-side rendering can finish after the network becomes quiet. Add an application readiness selector or event.
Why do colors still differ after enabling backgrounds?
Background painting and color adjustment are separate concerns. Check print color adjustment, the active media type, and the actual CSS variables applied in print.
Can CSS prevent every page split?
No. break-inside: avoid is a request. An element taller than the available page must still be split or moved.
When should I compare Puppeteer with Playwright?
After the HTML, browser version, dimensions, media mode, options, and readiness timing are fixed. Otherwise you are comparing several variables at once.


