· Tutorials · By Simon Chiu

URL to PDF in Node.js and Python, Tested on Live Pages

Convert a URL to PDF in Node.js and Python: print media, cookie banners that repeat on every page, lazy images that never load, with tested scripts.

To convert a URL to a PDF, open it in headless Chrome, let the page finish, and print it. In Node.js with Puppeteer that is page.goto(url) followed by page.pdf(); in Python with Playwright it is page.goto(url) followed by page.pdf(path=...). The two lines work on example.com. On a real site they give you a PDF with the cookie banner printed on every page and blank boxes where the images should be.

This tutorial fixes those two problems and the others that come with live pages, in both languages, with scripts we ran on 2026-10-18. It is part of our HTML to PDF API cluster. If you print your own HTML templates rather than other people's pages, the Puppeteer page.pdf() options guide is the better starting point; in Python, start with HTML to PDF in Python.

Tested on 2026-10-18, macOS (Apple Silicon). Node.js 22.18.0 with [email protected] (Chrome 148.0.7778.97). Python 3 with playwright==1.64.0 (Chromium 156.0.8078.4). Targets: https://example.com and two local test pages we wrote (a lazy-image page and a cookie-banner page), served with python -m http.server.

What goes wrong when you print a live URL

We wrote two small test pages to reproduce the failures we see most often, then printed each with and without the fixes.

Test Without the fix With the fix
Page with 2 loading="lazy" images and 1 image added by an IntersectionObserver, 4,000 px below the fold 0 of 3 images in the PDF 3 of 3 (Node and Python)
Page with a position: fixed consent banner Banner text on all 3 pages Banner on 0 pages (Node and Python)
@media print rules on the site Applied (both libraries print with print media) No change needed

The lazy-image result surprises people because the PDF has the full page height. Chrome lays out every element, including the empty image boxes, but it never fetched the files, because nothing scrolled them into view.

Node.js: a URL to PDF script that handles live pages

npm install [email protected]
// url-to-pdf.mjs: node url-to-pdf.mjs <url> <out.pdf>
import { writeFile } from 'node:fs/promises';
import puppeteer from 'puppeteer';

const [url, out] = process.argv.slice(2);
if (!url || !out) {
  console.error('usage: node url-to-pdf.mjs <url> <out.pdf>');
  process.exit(2);
}

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setViewport({ width: 1280, height: 800 });
  const response = await page.goto(url, { waitUntil: 'networkidle2', timeout: 30_000 });
  if (!response || !response.ok()) throw new Error(`HTTP ${response?.status()} for ${url}`);

  // Scroll to the bottom in steps so loading="lazy" images and IntersectionObserver content load.
  await page.evaluate(async () => {
    for (let y = 0; y < document.body.scrollHeight; y += window.innerHeight) {
      window.scrollTo(0, y);
      await new Promise((r) => setTimeout(r, 150));
    }
    window.scrollTo(0, 0);
  });
  // Wait for every <img> to finish (or fail) and for web fonts.
  await page.evaluate(async () => {
    await Promise.all([...document.images].map((img) => img.complete || img.decode().catch(() => {})));
    await document.fonts.ready;
  });

  // Hide common consent banners before printing. Extend the selector list for your targets.
  await page.addStyleTag({ content: `
    #onetrust-consent-sdk, #CybotCookiebotDialog, .cc-window, [id*="cookie-banner"],
    [class*="cookie-consent"] { display: none !important; }` });

  await page.emulateMediaType('print');
  const pdf = await page.pdf({
    format: 'A4',
    printBackground: true,
    margin: { top: '12mm', bottom: '12mm', left: '10mm', right: '10mm' },
  });
  await writeFile(out, pdf);
  console.log(`${out}: ${pdf.length} bytes`);
} finally {
  await browser.close();
}

Why each step is there:

  • response.ok(): goto resolves for a 404 or 500 page too. Without the check you get a well-formatted PDF of an error page and a zero exit code.
  • networkidle2: waits until no more than two requests are open for 500 ms. Pages with analytics beacons or long polling never reach full idle, which is why we do not use networkidle0 for third-party sites.
  • The scroll loop: this is what took our lazy-image test from 0 to 3 images. A 150 ms pause per screen was enough for local files; raise it for slow CDNs.
  • img.decode(): resolves when the image is ready to paint. The .catch keeps one broken image from failing the whole PDF.
  • The style tag: a CSS rule wins against banners that are injected late, because it applies to elements that appear after it is added. Clicking "Accept" also works, but it sets consent cookies and differs per vendor.
  • emulateMediaType('print'): page.pdf() already uses print media. We set it explicitly so a later edit that adds emulateMediaType('screen') for debugging is easy to see.

example.com printed as one A4 page of 114,583 bytes. The whole script, including launching Chrome, took 3.46 s on the first run and 2.44 s and 2.39 s on the next two.

Python: the same conversion with Playwright

Playwright for Python has a synchronous API that suits scripts and Celery or RQ jobs. Install the package and its Chromium:

pip install playwright==1.64.0
python -m playwright install chromium
"""url_to_pdf.py: python url_to_pdf.py <url> <out.pdf>"""
import sys

from playwright.sync_api import sync_playwright

HIDE_BANNERS = """
#onetrust-consent-sdk, #CybotCookiebotDialog, .cc-window, [id*="cookie-banner"],
[class*="cookie-consent"] { display: none !important; }
"""

SCROLL_AND_SETTLE = """
async () => {
  for (let y = 0; y < document.body.scrollHeight; y += window.innerHeight) {
    window.scrollTo(0, y);
    await new Promise((r) => setTimeout(r, 150));
  }
  window.scrollTo(0, 0);
  await Promise.all([...document.images].map((img) => img.complete || img.decode().catch(() => {})));
  await document.fonts.ready;
}
"""


def main(url: str, out: str) -> None:
    with sync_playwright() as p:
        browser = p.chromium.launch()
        try:
            page = browser.new_page(viewport={"width": 1280, "height": 800})
            response = page.goto(url, wait_until="networkidle", timeout=30_000)
            if response is None or not response.ok:
                raise RuntimeError(f"HTTP {response.status if response else '?'} for {url}")
            page.evaluate(SCROLL_AND_SETTLE)
            page.add_style_tag(content=HIDE_BANNERS)
            page.emulate_media(media="print")
            page.pdf(path=out, format="A4", print_background=True,
                     margin={"top": "12mm", "bottom": "12mm", "left": "10mm", "right": "10mm"})
        finally:
            browser.close()


if __name__ == "__main__":
    if len(sys.argv) != 3:
        sys.exit("usage: python url_to_pdf.py <url> <out.pdf>")
    main(sys.argv[1], sys.argv[2])

The differences from the Node version are Playwright idioms, not style:

  • Playwright's networkidle means no requests for 500 ms. There is no "two requests open" variant, so on a chatty site it can wait until the timeout. If that happens, use wait_until="load" and then page.wait_for_selector() on the element you need.
  • page.pdf() in Playwright works only in Chromium. Firefox and WebKit raise an error.
  • An uncaught exception gives a non-zero exit code, so a failed conversion fails the cron job or the CI step that ran it.

On example.com the Python script ran in 1.55 to 1.89 s over three runs and wrote one A4 page of 189,176 bytes. The Node file was 114,583 bytes for the same page. The difference comes from the browser builds (Chromium 156 against Chrome 148), not from Python, so pin the browser version if you compare file sizes over time.

Both libraries print with the print media type unless you change it. That is usually right for articles and docs, whose print stylesheets hide the header, the sidebar and the comments. It is often wrong for dashboards and web apps that never wrote print CSS. Their print output can lose its layout grid or its colored backgrounds.

Test both and keep the better one:

await page.emulateMediaType('screen'); // Node
page.emulate_media(media="screen")  # Python

With screen media, also set the viewport width you want the layout at. A 1280 px layout scaled onto A4 makes small text; scale: 0.8 (Node) or scale=0.8 (Python) in pdf() can help, and landscape A4 fits wide tables.

Pages behind a login

Do not script the login form with a user's password. Reuse a session instead:

// Node: set the session cookie before goto
await browser.setCookie({ name: 'session', value: process.env.SESSION_COOKIE, domain: 'app.example.com', path: '/', httpOnly: true, secure: true });
# Python: add the cookie to the context before goto
context = browser.new_context()
context.add_cookies([{"name": "session", "value": os.environ["SESSION_COOKIE"],
                      "domain": "app.example.com", "path": "/", "httpOnly": True, "secure": True}])
page = context.new_page()

For bearer-token APIs, page.setExtraHTTPHeaders({ Authorization: ... }) in Node and context.set_extra_http_headers(...) in Python send the header with every request, including requests to third-party hosts. If the page loads assets from other domains, route the header only to your own origin. We did not run the cookie snippets for this page.

Converting many URLs

A script that launches Chrome per URL spends most of its time on the launch. For a list of URLs:

  1. Launch the browser once.
  2. Open a new page (Node) or a new context (Python) per URL, and close it in finally.
  3. Run a fixed number at a time. Start low (a few pages at a time) and measure memory before you raise it.
  4. Write failures to a list and retry them once at the end. A site that timed out once often works on the second try.

Our Puppeteer timeouts and memory leaks post covers the browser lifecycle in depth.

Troubleshooting

Symptom Likely cause Fix
Blank boxes where images should be Lazy loading, nothing scrolled Run the scroll pass before pdf()
Cookie banner on every page position: fixed repeats per printed page Hide it with CSS or dismiss it before pdf()
PDF of a 404 or login page No status check after goto Check response.ok() / response.ok and exit non-zero
Timeout on networkidle Analytics or long polling never stop Use load plus a selector wait
Text in a fallback font Printed before web fonts loaded await document.fonts.ready
Navigation and sidebar in the PDF Site has no print stylesheet Add your own @media print rules with addStyleTag
Content cut off at the right edge Fixed-width layout wider than the paper Lower scale, use landscape, or narrow the viewport
Chat widget over the text Widget is fixed like the banner Add its selector to the hidden list
Different output on the server Missing fonts in the container Install fonts-noto-core, fonts-noto-cjk and an emoji font
page.pdf() raises an error in Playwright The script launched Firefox or WebKit PDF printing is Chromium-only; use p.chromium.launch()

The same conversion as one API call

If you would rather not keep the selector list and the scroll loop up to date, our API does the banner blocking and the waiting on its side. This request sends the same options as the scripts above: print media, banner blocking, network-idle wait, A4 with backgrounds. It follows the PDF options, blocking and browser docs; it was not run for this page.

curl -X POST https://api.renderscreenshot.com/v1/screenshot \
  -H "Authorization: Bearer $RS_API_KEY" \
  -H "Content-Type: application/json" \
  --fail --output page.pdf \
  -d '{
    "url": "https://example.com",
    "output": { "format": "pdf" },
    "browser": { "media": "print" },
    "block": { "cookie_banners": true, "chat_widgets": true },
    "wait": { "until": "networkidle", "timeout": 30 },
    "pdf": { "paper": "a4", "print_background": true, "margin": "12mm" }
  }'

The Python SDK version, from the Python SDK reference:

import os

from renderscreenshot import Client, TakeOptions

client = Client(os.environ["RS_API_KEY"])
pdf = client.take(
    TakeOptions.url("https://example.com")
    .format("pdf")
    .media_type("print")
    .block_cookie_banners()
    .block_chat_widgets()
    .wait_for("networkidle")
    .pdf_paper_size("a4")
    .pdf_print_background()
    .pdf_margin("12mm")
)
with open("page.pdf", "wb") as f:
    f.write(pdf)

A failed render returns an error code you can act on, such as timeout or navigation_failed, instead of a PDF of an error page. For lazy content that needs a specific element, add wait.for_selector. Our wait strategies guide explains the options. You can get a free API key and run the curl above against the URL that gave you trouble.

What we ran and what we did not

Ran on 2026-10-18: url-to-pdf.mjs and url_to_pdf.py against https://example.com (three runs each), and both scripts plus a no-fix control against our local lazy-image and banner pages. Image counts come from pdfimages -list; banner checks from pdftotext; page counts and sizes from pdfinfo.

Not run: the login cookie snippets, the RenderScreenshot curl and SDK calls, and any test on third-party sites other than example.com. The timings are from one laptop and are only useful to compare the two scripts with each other. We produced no rendered images for this post.

Related in this topic