DEV Community

Dylan Xu
Dylan Xu

Posted on

Building a URL-to-PDF API with Playwright: the edge cases, and how to make the PDF fillable

Introduction

If you've ever needed to add "export to PDF" to a web application, you've probably hit a wall. The usual path is:

  1. Try wkhtmltopdf → your modern HTML/CSS looks wrong because it doesn't execute JavaScript
  2. Google around → find five managed API services, none of which quite fit
  3. Give up and self-host Puppeteer / Playwright → works great, until you have to maintain it

This post is about option 3 — how to actually build a URL-to-PDF API with Playwright, and all the edge cases that bite you once you try to run it for real users.

I built snapdok as a managed service doing exactly this. These are the problems I had to solve.


Why not wkhtmltopdf?

You'll still see wkhtmltopdf recommended in Stack Overflow answers. Here's why you shouldn't use it for new projects:

  1. No JavaScript execution. wkhtmltopdf uses QtWebKit frozen circa 2014. Any page that relies on client-side rendering — React, Vue, Angular, even vanilla JS — will render as blank or broken HTML. This is a dealbreaker if your pages use modern frameworks.

  2. Abandoned. The GitHub repository is archived, and its last commit landed in November 2022.

  3. Security vulnerability. CVE-2022-35583 is an SSRF (Server-Side Request Forgery) vulnerability rated CVSS 9.8 (critical). The project's own documentation warns against running it with untrusted HTML. This vulnerability is unpatched because the project is archived.

Playwright (or Puppeteer) with real Chromium solves all three. Let's build it.


The Basic Setup

npm install playwright fastify
npx playwright install chromium
Enter fullscreen mode Exit fullscreen mode

Your basic render function:

import { chromium } from 'playwright';

export async function renderPage(url, format = 'pdf') {
  const browser = await chromium.launch({ headless: true });
  const page = await browser.newPage();

  await page.goto(url, { waitUntil: 'networkidle' });

  let result;
  if (format === 'pdf') {
    result = await page.pdf({ format: 'A4', printBackground: true });
  } else {
    result = await page.screenshot({ fullPage: true });
  }

  await browser.close();
  return result;
}
Enter fullscreen mode Exit fullscreen mode

This works for simple pages. But real-world pages will break it in a dozen different ways.


Problem 1: waitUntil: 'networkidle' isn't enough

networkidle waits for 500ms of no network activity. But some pages:

  • Load fonts asynchronously after the main content
  • Use CSS animations that prevent networkidle from firing
  • Have tracking pixels that keep firing indefinitely

The fix: combine networkidle with an explicit wait for a "ready" signal:

await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });

// Optional: wait for a specific element that signals "render complete"
// Your app can signal readiness with a CSS class or custom event
try {
  await page.waitForSelector('.render-ready', { timeout: 2000 });
} catch {
  // No signal? Wait a bit more for good measure
  await page.waitForTimeout(500);
}
Enter fullscreen mode Exit fullscreen mode

If you control the page being rendered (like an invoice template in your own app), add a render-ready class to the body once your data is loaded. Playwright can wait for it precisely.


Problem 2: Lazy-loaded images come out blank

Many modern pages use loading="lazy" or IntersectionObserver-based lazy loading. When Playwright captures the page, the viewport might not have scrolled to trigger image loads.

The fix: scroll through the page before capturing:

async function scrollPageToLoadImages(page) {
  const bodyHeight = await page.evaluate(() => document.body.scrollHeight);
  const viewportHeight = page.viewportSize().height;

  let scrollY = 0;
  while (scrollY < bodyHeight) {
    await page.evaluate(y => window.scrollTo(0, y), scrollY);
    await page.waitForTimeout(100);
    scrollY += viewportHeight;
  }

  // Scroll back to top for the capture
  await page.evaluate(() => window.scrollTo(0, 0));
  await page.waitForTimeout(300);
}

await scrollPageToLoadImages(page);
result = await page.screenshot({ fullPage: true });
Enter fullscreen mode Exit fullscreen mode

Problem 3: Fonts render as boxes on the server

Your local machine has the fonts. Your Linux server probably doesn't.

Minimum font packages for Ubuntu/Debian:

apt-get install -y \
  fonts-liberation \
  fonts-noto \
  fonts-noto-cjk \
  fonts-noto-color-emoji \
  fontconfig
Enter fullscreen mode Exit fullscreen mode

fonts-noto-cjk is specifically important if any of your pages render Chinese, Japanese, or Korean characters. Without it, those characters show as empty boxes. Most PDF API services don't bother with CJK fonts — check yours if you have international users.

After installing, run fc-cache -f -v to rebuild the font cache.


Problem 4: PDF page breaks cut content in half

Playwright's page.pdf() will break pages at fixed intervals, which often means a table header at the bottom of one page and its rows at the top of the next, or an image split in half.

You can control this with CSS:

/* Prevent page breaks inside these elements */
table, figure, .invoice-line-item, blockquote {
  break-inside: avoid;
}

/* Force a page break before these */
.page-break-before {
  break-before: page;
}

/* Allow breaks only between top-level sections */
.section {
  break-before: avoid;
  break-after: auto;
}
Enter fullscreen mode Exit fullscreen mode

In Playwright, enable background printing so CSS is respected:

await page.pdf({
  format: 'A4',
  printBackground: true,  // Required for background colors and CSS print styles
  margin: { top: '1cm', right: '1cm', bottom: '1cm', left: '1cm' }
});
Enter fullscreen mode Exit fullscreen mode

Problem 5: Full-page height detection breaks with sticky elements

When you use screenshot({ fullPage: true }), Playwright measures document.body.scrollHeight to determine the full page height. But sticky headers/footers stay in place and can cause the height to be computed incorrectly.

// Temporarily hide sticky elements for full-page capture
await page.evaluate(() => {
  const sticky = document.querySelectorAll('[style*="position: sticky"], [style*="position: fixed"]');
  sticky.forEach(el => el.dataset.origDisplay = el.style.display);
  sticky.forEach(el => el.style.display = 'none');
});

const screenshot = await page.screenshot({ fullPage: true });

// Restore them
await page.evaluate(() => {
  const sticky = document.querySelectorAll('[data-orig-display]');
  sticky.forEach(el => el.style.display = el.dataset.origDisplay);
});
Enter fullscreen mode Exit fullscreen mode

Problem 6: A PDF of a form isn't a form

This is the one I found most interesting, and the one I couldn't find written up anywhere.

page.pdf() gives you a picture of the page. If that page contains an HTML form, what you get is a picture of a form: boxes drawn on paper. If a human has to fill it in, they print it, use a pen, and scan it back.

PDF has had a solution to this since forever — AcroForm fields. Real text inputs, checkboxes, radio groups and dropdowns, embedded in the document, editable in Acrobat, macOS Preview and every phone PDF reader. Playwright won't produce them, but you can add them afterwards with pdf-lib:

import { PDFDocument } from 'pdf-lib';

const pdfDoc = await PDFDocument.load(rawPdfBytes);
const form = pdfDoc.getForm();
const page = pdfDoc.getPages()[0];

const field = form.createTextField('email');
field.addToPage(page, { x: 100, y: 500, width: 200, height: 24 });
Enter fullscreen mode Exit fullscreen mode

Creating the field is easy. Knowing where to put it is the whole problem.

The obvious approach, and why it breaks

Measure the control in the browser, convert units, place the field:

const rect = await page.evaluate(() => {
  const el = document.querySelector('#email');
  const r = el.getBoundingClientRect();
  return { x: r.left + scrollX, y: r.top + scrollY, w: r.width, h: r.height };
});

const PT_PER_PX = 0.75;  // 96 CSS px/inch → 72 pt/inch
Enter fullscreen mode Exit fullscreen mode

That conversion factor is exact and it is not the problem. The problem is that Chromium's print pipeline is not a scaled screenshot of the screen layout. It re-lays-out the document for the paper size. Margins get added, content shrinks to fit the width, and elements reflow. The moment your form spills onto page two, a document-space Y coordinate tells you nothing about where the control actually printed — or even which page it printed on.

You can try to model this: subtract the margins, apply the shrink factor, divide by page height to work out the page index. I tried. Every page-break rule (break-inside: avoid, a table that won't split, a heading that pulls its section along) is another correction, and you are essentially reimplementing a layout engine that is already running right there.

What actually works: let the document tell you

Instead of predicting where each control will print, make each control leave a mark in the printed output, then read the mark back.

Before rendering, inject a tiny invisible token next to every control:

await page.evaluate(() => {
  const controls = document.querySelectorAll('input, textarea, select');
  controls.forEach((el, i) => {
    const r = el.getBoundingClientRect();
    const span = document.createElement('span');
    span.textContent = `zqW${i}qz`;          // unlikely to occur naturally
    span.style.cssText =
      'position:absolute;font:400 4px/1 Arial,sans-serif;color:#ffffff;' +
      'margin:0;padding:0;border:0;white-space:pre;';
    span.style.left = (r.left + scrollX) + 'px';
    span.style.top  = (r.top  + scrollY) + 'px';
    document.documentElement.appendChild(span);
  });
});
Enter fullscreen mode Exit fullscreen mode

4px and white, so it's invisible in the output — but Chromium still prints it, and it lands in the PDF's text layer. Render the PDF, then parse that text layer (pdf.js works well for this) and search for each token. What you get back is exactly what you needed and couldn't compute: the page index, and the coordinates in that page's own space. Place the AcroForm field there.

The property that makes this worth it: you never need a model of Chromium's pagination. Margins, scaling, page breaks, a header that pushes everything down — whatever Chromium does to the layout, it does to the token too, and the field follows. The document reports where it printed instead of you trying to predict it.

Two bugs worth stealing the fixes for

1. Absolute positioning isn't always relative to the document origin. I appended the tokens with position: absolute and set left/top to the control's document coordinates. On most pages, perfect. On some, everything was off by a consistent few pixels — because position: absolute resolves against the nearest positioned ancestor, and things like a positioned <html> element or a body margin move that origin.

Rather than enumerate the causes, make it self-correcting: place the token, measure where it actually ended up, and nudge it by the difference.

let pr = span.getBoundingClientRect();
const dx = targetX - (pr.left + scrollX);
const dy = targetY - (pr.top  + scrollY);
if (Math.abs(dx) > 0.01 || Math.abs(dy) > 0.01) {
  span.style.left = (parseFloat(span.style.left) + dx) + 'px';
  span.style.top  = (parseFloat(span.style.top)  + dy) + 'px';
}
Enter fullscreen mode Exit fullscreen mode

One extra measurement, and an entire category of "why is it 8px off on this one site" disappears.

2. Radio groups die if you deduplicate names. AcroForm field names must be unique — except radio buttons, where a shared name is exactly what makes several buttons into one group where selecting one clears the others. I was uniquifying every name blindly, which turned each group into a set of unrelated checkboxes. Visually identical, completely wrong: every option independently checkable. Radio inputs have to be exempted from deduplication and collected into a single createRadioGroup.

Know what you can't do, and report it

Some controls have no honest PDF equivalent, and some can't be located reliably. Skip those on purpose:

Skip Why
file, range, color No AcroForm equivalent. Faking one misleads the user.
hidden, submit, button, reset, image Not data entry.
display:none, visibility:hidden, opacity:0 Not visible in the output, so nothing to anchor to.
Under 4px, or off-screen Not a real control, or unreachable.
CSS transform The rect misreports the painted position.
position: fixed Repeats on every printed page — "which page" has no answer.
JS-drawn fake controls A div styled as a dropdown isn't in the DOM as a control. You read the DOM, not pixels.

The important part isn't the skipping, it's telling the caller what you skipped. "Your form had 9 fields and you got 9" and "your form had 9 fields and you got 6" look identical if you stay quiet — and the second is the one that hurts, because it fails silently and looks fine. Return the skip list in a response header or the response body.

Field types that map cleanly: text, email, tel, url, search, password, number, date, time, month, week, textarea (multiline), checkbox, radio groups, and select (dropdown). Carry over the existing values, the checked state, maxlength, and map disabled/readonly to a read-only field.


Managing Browser Lifecycle at Scale

Opening and closing a browser for every request is safe but slow (~500ms per launch). For production, maintain a browser pool:

class BrowserPool {
  constructor(maxSize = 5) {
    this.pool = [];
    this.maxSize = maxSize;
    this.queue = [];
  }

  async acquire() {
    if (this.pool.length > 0) {
      return this.pool.pop();
    }
    return await chromium.launch({ headless: true });
  }

  release(browser) {
    if (this.pool.length < this.maxSize) {
      this.pool.push(browser);
    } else {
      browser.close();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Be careful: a crashed page can put the browser in a bad state. Track failures per browser instance and recycle after N failures.


The Fastify API Layer

import Fastify from 'fastify';
const app = Fastify();

app.post('/v1/render', async (request, reply) => {
  const { url, format = 'pdf' } = request.body;

  if (!url || !['pdf', 'png'].includes(format)) {
    return reply.code(400).send({ error: 'url and format (pdf|png) required' });
  }

  const browser = await pool.acquire();
  const page = await browser.newPage();

  try {
    await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
    await scrollPageToLoadImages(page);

    const contentType = format === 'pdf' ? 'application/pdf' : 'image/png';
    const buffer = format === 'pdf'
      ? await page.pdf({ format: 'A4', printBackground: true })
      : await page.screenshot({ fullPage: true });

    await page.close();
    pool.release(browser);

    return reply
      .header('Content-Type', contentType)
      .header('Content-Disposition', `attachment; filename="render.${format}"`)
      .send(buffer);

  } catch (err) {
    await page.close().catch(() => {});
    browser.close(); // Don't return a failed browser to pool
    throw err;
  }
});
Enter fullscreen mode Exit fullscreen mode

Hosting Considerations

  • RAM: Chromium is hungry. Budget ~1GB per concurrent browser instance.
  • Concurrency: 4 concurrent renders on a 4GB server is about the limit before OOM issues.
  • Queueing: Add a queue (Redis + bull, or even an in-memory queue for small scale) to limit concurrency.
  • Chromium updates: Playwright pins its Chromium version. Run npx playwright install as part of your deploy process.

What I Built

After solving these problems for my own projects, I turned it into snapdok — a managed version so you don't have to own any of the above.

POST /v1/render with a URL and a format, the file comes back in the response body. No job ID, no polling, no webhook. Add "pdf_forms": true and you get the AcroForm treatment from Problem 6 — the page's HTML form comes back as fields you can type into.

curl -X POST https://snapdok.io/v1/render \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://your-app.com/intake-form","format":"pdf","pdf_forms":true}' \
  -o form.pdf
Enter fullscreen mode Exit fullscreen mode

On the fillable-form part specifically, there's a real fork in the road. If you want AcroForm output today, the established answer is a native library you install and license — IronPDF, HiQPDF and that family. They're good, and if you're generating one document at a time inside a desktop app they're probably the right call.

Where that shape gets uncomfortable is volume. You're installing a runtime, licensing per machine or per developer, and then building the parts nobody sells you: the concurrency limiting, the retries, the crash recovery from exactly the browser-lifecycle problems in the section above. That's the same work whether you're producing ten documents or ten thousand, and it grows with your fleet. An HTTP call doesn't have a fleet. One document and a thousand documents are the same line of code, and there's nothing to install.

Two billing details that only matter once you're doing volume, but matter a lot then: a render that fails isn't counted — timeouts and errors don't bill, so a batch that loses forty documents in the middle doesn't charge you for forty nothings — and quotas are hard caps, not metered overage. When you hit the limit you get a 402, not a surprise invoice. A runaway loop in a batch script costs you a stack of 402s instead of a bill. Form fields are a base feature on every plan, free tier included, not a paid add-on.

If you want to see whether the rendering is actually any good before reading a single line of docs, there's a demo box on the homepage — paste a URL, get a PNG or PDF back, no signup and no email. Pick PDF and any form on the page comes back fillable, with a sample form linked if you don't have one handy. It's the same Chromium that serves the paid API, and the output has no watermark, so what you see is genuinely what you'd get.

It's only a few days old and I'm the only developer. If you're comfortable running Playwright yourself, you probably should — it's free, and everything in this post applies directly to your own build, including Problem 6. This exists for the case where you'd rather not own the ops.


Summary

Building a URL-to-PDF API with Playwright is absolutely doable. The pieces that'll bite you:

  1. networkidle isn't always a reliable "done" signal — add explicit waits
  2. Lazy-loaded images require scrolling the page before capture
  3. CJK fonts need to be explicitly installed on the server
  4. PDF page breaks need CSS guidance (break-inside, break-before)
  5. Sticky elements mess up full-page height calculations
  6. AcroForm fields can't be placed from browser coordinates — print an invisible token with each control and read back where it actually landed
  7. Browser pool management is necessary at any real scale

Questions? Drop them in the comments. I read everything.

Top comments (1)

Collapse
 
amitfeldman profile image
Amit Feldman

The invisible-token trick for placing AcroForm fields is genuinely clever — reading back where the browser actually landed a control instead of trusting source coordinates is the kind of detail you only learn the hard way. And calling out the wkhtmltopdf SSRF CVE upfront is rarer than it should be in this category.

Since you clearly think about the security side: I ran a quick launch check on snapdok.io. TLS is fine (valid cert, TLS 1.3), TTFB is 183ms, SEO basics are clean — but all six browser-security headers are missing: HSTS, Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy. For a service whose whole job is fetching arbitrary user-supplied URLs, those are the cheap baseline — especially nosniff and CSP, since rendered HTML from untrusted pages is exactly your input.

You're on bare nginx, so this is one config block: six add_header lines in the server block, reload, no redeploy. While you're in there, server_tokens off; — the Server header currently announces "nginx/1.28.3 (Ubuntu)", which hands anyone a version and OS to match CVEs against.

Happy to re-scan once it lands and confirm it's all green.