Use Puppeteer’s page.addStyleTag({ url }), await it, and only then call page.pdf(). Navigate to the document with an explicit wait condition first, select the correct media type, wait for fonts and other late assets, and enable the PDF options that preserve backgrounds and CSS page sizes.
The complete pattern is: page.goto() → page.addStyleTag({url: cssUrl}) → optional media/font/asset waits → page.pdf(). If the stylesheet still does not appear, investigate print-media rules, failed network requests, authentication, CSP, redirects and nested resources.
Working Puppeteer example
This runnable ES module loads an invoice page, injects a stylesheet by URL, waits for the stylesheet promise to resolve, and writes an A4 PDF.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://example.com/invoice.html', {
waitUntil: 'networkidle2'
});
await page.addStyleTag({
url: 'https://cdn.example.com/print.css'
});
await page.pdf({
path: 'invoice.pdf',
format: 'A4',
printBackground: true,
preferCSSPageSize: true
});
await browser.close();
addStyleTag({url}) inserts a <link rel="stylesheet"> element. Its promise resolves after the stylesheet has loaded (or CSS content has been injected), so awaiting it prevents the most common race with page.pdf().
Do these 3 things before closing this tab:
1Fix the driver behind crashes, sound loss and screen glitches2Repair Windows errors before they cause bigger problems3Scan for outdated or missing drivers - takes under a minute#1 Best Overall
Use a local project
Create a project, install Puppeteer, and run the file as an ES module:
mkdir pdf-css-demo
cd pdf-css-demo
npm init -y
npm install puppeteer
# add "type": "module" to package.json
node render.mjs
Chromium must be able to reach the HTML URL, the CSS URL, fonts, images and any resources referenced by @import. A stylesheet URL that works in your desktop browser can fail in the browser process running in a container or server.
Why an external stylesheet is missing
Print media is the default
Puppeteer’s PDF API generates a PDF using the print CSS media type. Rules inside @media screen, or declarations that are overridden by print rules, therefore may not be visible. If the design intentionally uses screen styles, select screen media before rendering:
await page.emulateMediaType('screen');
await page.pdf({
path: 'screen-styled.pdf',
printBackground: true
});
Alternatively, move the required declarations into normal rules or an @media print block. Choosing screen media changes the cascade for the entire page, so verify margins, colors and page breaks rather than assuming the browser view and PDF will match.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
The link is injected too late
Calling page.pdf() immediately after adding a link can capture the old style state. Await both navigation and addStyleTag(). For applications that continue changing the DOM after navigation, wait for a stable selector or a short, justified delay as well.
Rank #2
await page.goto(url, { waitUntil: 'networkidle2' });
await page.addStyleTag({ url: cssUrl });
await page.waitForSelector('#invoice-ready');
await page.pdf({ path: 'invoice.pdf', printBackground: true });
networkidle2 waits for a period with no more than two active connections. Analytics, polling and WebSockets can keep a page “busy” indefinitely; in that case, use a less strict navigation condition and an application-specific readiness selector.
The browser cannot fetch the resource
Remote CSS can be blocked by DNS failures, TLS problems, redirects, an origin that requires authentication, a content-security policy, or request interception in your own code. Fonts and images can fail independently even when the CSS request succeeds. The PDF then contains a partially styled page.
Attach diagnostics before navigation so you can identify the failing URL and the browser’s console message:
Quick wins for a faster PC:
Repair Windows errors before they cause bigger problemsFix Now →Scan for outdated or missing drivers - takes under a minuteDriver Scan →page.on('requestfailed', request => {
console.error('Request failed:', request.url(), request.failure()?.errorText);
});
page.on('console', message => {
console.log('Browser console:', message.type(), message.text());
});
Run the same script in the target container or CI image. “Works locally” does not prove that the deployment environment has identical network access, certificates, proxy settings or credentials.
Waiting for fonts, images and application-rendered CSS
Fonts
Puppeteer’s PDF guide states that PDF generation waits for fonts by default. If your page manages fonts asynchronously, make the readiness condition explicit and use the PDF controls available in your installed Puppeteer version:
Rank #3
await page.evaluate(async () => {
if (document.fonts) await document.fonts.ready;
});
await page.pdf({
path: 'invoice.pdf',
format: 'A4',
printBackground: true,
waitForFonts: true,
timeout: 60000
});
Keep waitForFonts and timeout aligned with the Puppeteer version you deploy. A timeout is a failure signal to handle, not a guarantee that a remote font exists.
Images and lazy content
A stylesheet can be present while images are still loading or lazy components have not rendered. Wait for a page-owned marker such as #invoice-ready, or wait for image completion when you control the markup:
await page.waitForFunction(() => {
return [...document.images].every(img => img.complete);
});
For an application that renders after an API call, expose a deterministic marker in the page and wait for it instead of guessing with a long delay.
Nested imports
The browser must also retrieve URLs in @import, url() font declarations and background images. Check each request in the diagnostic handler. A successful top-level CSS response does not mean every dependency was reachable.
PDF options that affect the result
| Option | Effect | When to use it |
|---|---|---|
printBackground |
Includes background colors and images that are omitted by default. | Set true for branded invoices, cards and shaded sections. |
preferCSSPageSize |
Lets CSS @page size and orientation take priority over the PDF format, width or height. |
Set true when the stylesheet defines the paper size. |
format |
Chooses a standard paper size such as A4. | Use when the document does not define its own @page size. |
landscape |
Rotates the selected paper orientation. | Use for wide tables or reports. |
waitForFonts |
Controls waiting for document fonts before PDF generation. | Keep enabled for web fonts; increase the timeout for slow, legitimate assets. |
timeout |
Limits how long PDF generation waits. | Set an explicit value appropriate for your deployment and catch timeout errors. |
Do not set conflicting sizing rules accidentally. If preferCSSPageSize:true is enabled and the stylesheet contains @page, that CSS size wins over format. Inspect the generated PDF on the paper size your users actually print.
Rank #4
A more defensive production script
This version adds a stylesheet, waits for a known application marker and records failed requests. Replace the marker with one your page sets after data and layout are ready.
import puppeteer from 'puppeteer';
const htmlUrl = 'https://example.com/invoice.html';
const cssUrl = 'https://cdn.example.com/print.css';
const browser = await puppeteer.launch({
// Configure proxy, sandbox and executablePath here when your host requires it.
});
try {
const page = await browser.newPage();
page.on('requestfailed', request => {
console.error(JSON.stringify({
type: 'requestfailed',
url: request.url(),
error: request.failure()?.errorText
}));
});
page.on('console', message => {
console.error(`[browser:${message.type()}] ${message.text()}`);
});
await page.goto(htmlUrl, {
waitUntil: 'networkidle2',
timeout: 60000
});
await page.addStyleTag({ url: cssUrl });
await page.waitForSelector('#invoice-ready', { timeout: 30000 });
await page.evaluate(async () => {
if (document.fonts) await document.fonts.ready;
});
await page.pdf({
path: 'invoice.pdf',
format: 'A4',
printBackground: true,
preferCSSPageSize: true,
waitForFonts: true,
timeout: 60000
});
} finally {
await browser.close();
}
If the CSS endpoint requires cookies or an authorization header, establish them in the page context before injecting the link, or use request interception to attach headers. Ensure that the policy on the HTML origin permits the stylesheet; a CSP can reject an otherwise valid URL. Never log secret header values while diagnosing.
Playwright equivalent
Playwright exposes the same URL form of addStyleTag and generates PDFs with print media by default. The equivalent script is:
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://example.com/invoice.html', {
waitUntil: 'networkidle'
});
await page.addStyleTag({
url: 'https://cdn.example.com/print.css'
});
await page.pdf({
path: 'invoice.pdf',
format: 'A4',
printBackground: true
});
await browser.close();
To render screen rules in Playwright, call page.emulateMedia({ media: 'screen' }) before page.pdf(). As with Puppeteer, await the stylesheet and choose a readiness condition for application-generated content.
Troubleshooting checklist
- No colors or background images: set
printBackground:true; also check whether the declarations are restricted to screen media. - Screen layout differs from the PDF: remember that print media is the default. Use
emulateMediaType('screen')only when screen rules are the intended design. - The injected stylesheet has no effect: verify that
await page.addStyleTag({url: cssUrl})is reached, inspectrequestfailed, and check redirects, CSP and authentication. - Some styles work but custom fonts do not: inspect font requests and await
document.fonts.ready; confirm the font URL is reachable from the browser host. - The PDF captures an unstyled first view: move navigation and stylesheet injection before
page.pdf(), then wait for a deterministic ready selector. networkidle2never completes: the site may poll or hold long-lived connections. UsewaitUntil:'domcontentloaded'or another suitable condition followed by an explicit selector wait.- CSS page dimensions are ignored: set
preferCSSPageSize:trueand check that the stylesheet’s@pagerule is actually loaded. - Works on a laptop but fails in production: compare Chromium version, outbound network policy, proxy/TLS configuration, sandbox settings and environment credentials.
- PDF generation times out: identify the slow request instead of raising the timeout blindly. Remove unreachable assets, provide a faster endpoint or set a documented timeout appropriate to the workload.
Performance, reliability and operating cost
Every PDF requires a Chromium page and the network work needed by the HTML, CSS, fonts and images. Reusing a browser process while creating and closing pages per job usually avoids repeated browser startup, but isolate jobs carefully so cookies, headers and DOM state cannot leak between users. Close pages and browsers in finally blocks.
Recommended Free Tools
There are no published benchmark figures in the API documentation for remote-CSS reliability or PDF throughput. Measure your own pages in the same region and container image you will operate. Record navigation time, stylesheet load time, font readiness, PDF duration, output size and failure reason. Set bounded timeouts, retry only transient network failures, and avoid retrying deterministic CSP or authentication errors.
Self-hosting means paying for Chromium runtime, memory, bandwidth and engineering time. It gives you control over headers, cookies, browser versions and private resources. A hosted service trades some browser control for less infrastructure; evaluate data handling, authentication support, page limits and pricing for your workload rather than assuming equivalent behavior.
Or skip the browser setup
ScreenshotNeo provides a website capture API that can return a PDF from one GET request. It handles the browser side for you and can also apply CSS and JavaScript, wait for a selector, delay or network idle, set headers and cookies, choose a user agent, timezone or geolocation, and capture full pages or selected elements.
For a URL that is already ready to render, call:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/invoice.html -o invoice.pdf
The same request in Python is:
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://example.com/invoice.html"},
timeout=90,
)
r.raise_for_status()
open("invoice.pdf", "wb").write(r.content)
And in Node.js:
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://example.com/invoice.html'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
require('node:fs').writeFileSync('invoice.pdf', Buffer.from(await res.arrayBuffer()));
See the ScreenshotNeo documentation for PDF parameters and authentication. Before capture it accepts cookie or consent banners and removes more than 60 known consent platforms, newsletter popups and chat widgets; each step can be turned off. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads and cache hits are not billed, and the response identifies the page verdict and billing status in headers. Its MCP server gives AI agents tools named take_screenshot, get_page_info and capture_pdf.
The Free plan includes 1,000 screenshots per month without a card. Paid plans start at $5 for 3,000 shots; yearly billing gives two months free. Every feature is available on every plan. When you want to avoid maintaining Chromium, sign up for ScreenshotNeo’s free plan.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

