I needed PDF copies of invoice pages for bookkeeping. The usual answer is Puppeteer, which means installing Chromium on your server, keeping it patched, and giving it a few hundred MB of RAM. On a small VPS that hurts. So I put headless Chromium behind a plain HTTP endpoint and now PDF generation is one GET request.
curl --get "https://snappdf.dedyn.io:8444/v1/pdf" \
--data-urlencode "url=https://en.wikipedia.org/wiki/PDF" \
--data-urlencode "format=A4" \
-o page.pdf
That's it. --data-urlencode handles the nested URL for you, and page.pdf comes back as a real Chromium print render, so CSS, web fonts, and layout all survive.
A few query params cover the cases I actually hit:
-
formatfor paper size (A4,Letter, and the other common ones) -
landscape=truefor wide tables -
scalefrom 0.1 to 2 when a page is too cramped or too small at 100% -
background=falseto drop background colors for cheaper printing -
waitForSelector=#invoice-totalwhen the page renders client-side and you need to wait for a specific element before snapshotting
The waitForSelector one matters more than it looks. Most URL-to-PDF converters screenshot React apps before the data loads and you get a PDF of a spinner.
I use it from a cron job that archives receipts, but it works anywhere you can make an HTTP request: a Zapier webhook, a GitHub Action, a fetch() in a route handler.
Code is on GitHub if you'd rather self-host it: https://github.com/clause-netizen/snappdf-api
If you try the demo endpoint and something breaks, tell me. It runs on a small box and I'd like to know where the limits are.
Top comments (1)
The
waitForSelectorparam is the detail that tells me you've actually hit real broken pages already, most tools ship without it and then wonder why every client-rendered site comes back as a screenshot of a spinner.Since you're asking where the limits are: on a small box, it's not per-request memory that gets you, it's an unbounded process lifetime. A single hung render (a bad page, an infinite redirect, a site that never fires load) can hold a Chrome process open indefinitely while requests queue behind it, since
browser.close()can itself hang if the renderer's wedged. The fix is a hard wall-clock deadline per request thatSIGKILLs the whole process tree once it passes N seconds, plus recycling the browser process every so many requests rather than keeping one alive forever. Chrome's memory footprint creeps up over thousands of navigations even with cleanpage.close()calls in between, so a long-lived pooled instance slowly turns into a swap or OOM problem instead of a leak you can actually find and fix. If you haven't added it yet,--disable-dev-shm-usageis worth it too,/dev/shmdefaults to a few dozen MB on a small VPS and Chrome quietly falls over on heavier pages without it. We run a similar headless-Chrome-behind-an-API setup ourselves, and it's almost always this combination, stuck renders plus memory creep, that causes a real outage, not Chrome version drift.