page.pdf() takes ten lines on a laptop. The trouble starts when the same code runs on a server, for real users, all day. These are the problems that show up, roughly in the order teams hit them, with a fix for each. At the end there is an honest look at when to keep running it yourself.
1. Memory: one busy minute takes down the app#
Every open page is a renderer process, and a heavy page can use a few hundred MB on its own. Five users asking for a PDF at the same moment on a small instance is enough to run out of memory. The kernel then kills something, and often it is your web server rather than Chrome.
What helps:
- Never launch Chrome inside the request handler. Put jobs on a queue and have a worker process them with a fixed concurrency.
- Keep browsers warm and give each job a fresh context. Launching Chromium costs a second or more; opening a new incognito context on a running browser is fast and keeps jobs isolated.
- Restart browsers regularly. Long-lived Chromium processes slowly grow. Recycle each one after a fixed number of jobs.
// one browser, many isolated jobs
const browser = await chromium.launch();
async function render(html) {
const context = await browser.newContext();
try {
const page = await context.newPage();
await page.setContent(html, { waitUntil: 'load', timeout: 15000 });
return await page.pdf({ format: 'A4', printBackground: true });
} finally {
await context.close();
}
}
2. Hung pages and zombie processes#
Some pages never finish loading: a websocket that stays open, an analytics script that retries forever, a site that answers with a slow trickle. Without a hard limit, a worker slot is gone for good. And in Docker, Chrome child processes that lose their parent stay around as zombies, because nothing reaps them.
What helps:
- A wall-clock timeout per job, not only Playwright's navigation timeout. When it fires, close the context; if closing hangs too, kill the browser and start a new one.
- An init process in the container (
dumb-init,tini, orinit: truein Compose) so orphaned processes are reaped. - Don't wait for
networkidlewithout a cap. Pages with live tickers or chat widgets never go quiet.
3. Fonts that turn into boxes#
A stock Linux image has almost no fonts. Your Turkish customer's name loses its ğ and ş, Japanese text becomes rows of empty boxes, and emoji disappear. It often goes unnoticed until a customer sends a screenshot.
On Debian or Ubuntu images:
apt-get install -y --no-install-recommends \
fontconfig fonts-noto-core fonts-noto-cjk fonts-noto-color-emoji \
fonts-liberation2 fonts-dejavu-core fonts-thai-tlwg fonts-indic
fc-cache -f
fonts-liberation2 matters more than it looks: it gives you metric-compatible stand-ins for Arial, Times and Courier, so layouts designed on Windows or a Mac don't reflow. Then render a test page in every script your customers use and look at it.
4. Cookie banners, ads and lazy images#
If you capture pages you don't control, most of them open with a consent pop-up. Ads shift the layout while it loads. Images below the fold are lazy-loaded and come out as grey boxes in a full-page shot.
What helps:
- Filter lists such as EasyList and the uBlock cookie lists, applied as request blocking and cosmetic hiding, plus CSS for the big consent platforms as a fallback.
- Scroll before a full-page capture so lazy images load, then scroll back to the top.
- Stop animations at capture time, so you don't catch a menu halfway through sliding in.
5. Your renderer can reach your own network#
This is the one that turns into a security incident. If users can send a URL, they can send http://169.254.169.254/ (the cloud metadata service), http://localhost:6379/ or an internal admin panel. A plain check on the URL is not enough: a public page can redirect to a private address, an iframe or a script can request one, and a hostname can resolve to a public IP at check time and a private one a second later (DNS rebinding).
What helps:
- Refuse private, loopback, link-local and metadata ranges, for IPv4 and IPv6, including disguised forms such as
0x7f.1or2130706433. - Check every request the page makes, not just the first URL.
- Send the browser's traffic through a small proxy that resolves DNS itself and connects only to the address it checked.
- As a last layer, block private ranges at the firewall for the render workers.
6. Serverless is its own project#
Running Chromium on AWS Lambda or similar is possible with slimmed-down builds, but you trade one set of problems for another: deployment size limits, cold starts that add a Chromium launch to the first request, a fresh font setup, and a concurrency model where every request may start its own browser. It works well for low, bursty volume; it gets expensive and slow for steady traffic.
When to keep running it yourself#
Self-hosting is the right call when:
- Documents must never leave your infrastructure.
- Volume is low and internal, and an occasional failed render is acceptable.
- You need things a hosted API doesn't offer, such as converting Office files or merging PDFs. Gotenberg is a solid open-source option for that.
- You need to script whole browser sessions, not just capture a page.
An API is the better deal when renders are customer-facing, volume is steady, or nobody on the team wants to be paged because a font is missing or a browser leaked memory overnight.
render-bolt does all of the above for you: a queue with warm browsers and recycling, hard timeouts, fonts for every major alphabet, ad and cookie-banner removal, and network checks on every request. The free plan has 100 renders a month with every feature, so you can compare the output with your own setup before deciding.