pdf-render-pool
v0.2.2
Published
A persistent, concurrency-capped headless-Chromium HTML-to-PDF renderer built on Puppeteer — one warm browser instead of launching/tearing down Chromium per PDF.
Maintainers
Readme
pdf-render-pool
A persistent, concurrency-capped headless-Chromium HTML→PDF renderer built on Puppeteer.
Launching and tearing down a full Chromium for every PDF costs ~1-2 seconds
and ~150-300MB of RSS. pdf-render-pool keeps one browser warm and gives
each render its own page, so a render only pays for opening a tab — plus a
concurrency cap so a burst of PDF requests queues instead of OOMing your
server.
Guarantees
- Concurrency-capped — at most
concurrencyrenders run at once (default 2); the rest queue FIFO. - Back-pressure — optionally cap the queue (
maxQueue) so overload fails fast withEQUEUEFULLinstead of piling up. - Bounded — every render has one
timeoutdeadline covering browser launch, page creation, content load and PDF generation. - Crash-safe — if Chromium dies mid-run, the next render transparently relaunches it.
- Isolated — every render runs in its own browser context (separate cookies, localStorage and cache), always closed afterwards, so renders can't leak pages or state into each other. Opt out with
isolate: false. - Memory-friendly — optionally close the browser when idle (
idleTimeout), or recycle it after N renders (maxRendersPerBrowser) without interrupting renders in flight.
Install
npm install pdf-render-pool puppeteerpuppeteer is a peer dependency — bring your own version (or puppeteer-core
with an explicit executablePath, see below) so this package doesn't force a
particular Chromium download strategy on you. No other runtime dependencies.
Usage
const { createPdfRenderPool } = require('pdf-render-pool');
// or: import { createPdfRenderPool } from 'pdf-render-pool';
// Create the pool ONCE per process and reuse it everywhere — it holds one
// persistent browser instance.
const { renderPdf, renderPdfFromUrl, closeBrowser } = createPdfRenderPool();
// From an HTML string
const invoice = await renderPdf('<h1>Invoice #42</h1>', { format: 'A4' });
// From a URL
const page = await renderPdfFromUrl('https://example.com/report', {
format: 'A4',
waitUntil: 'networkidle0',
});
// On graceful shutdown:
await closeBrowser();API
createPdfRenderPool(options?)
Returns { renderPdf, renderPdfFromUrl, closeBrowser, stats }.
createPdfRenderPool({
concurrency: 4, // max renders at once (default 2)
maxQueue: 100, // max renders waiting for a slot (default Infinity)
idleTimeout: 5 * 60_000, // close Chromium after 5 min without renders (default 0 = never)
maxRendersPerBrowser: 500, // recycle Chromium after 500 renders (default 0 = never)
waitUntil: 'domcontentloaded', // default load event for every render
assetTimeout: 3000, // ms images/fonts get before being cancelled (default 3000)
isolate: true, // fresh browser context per render (default true)
puppeteerModule: 'puppeteer', // or 'puppeteer-core'
launchOptions: { // merged over the sandbox-safe defaults
executablePath: '/usr/bin/chromium',
},
onInfo: (msg) => console.log(msg),
onWarn: (msg) => console.warn(msg),
});Default launch flags (--no-sandbox, --disable-setuid-sandbox,
--disable-dev-shm-usage, --disable-gpu) target typical Linux
servers/containers where Chromium runs as non-root with a tiny /dev/shm.
Override them via launchOptions.
maxRendersPerBrowser caps how many renders one Chromium serves. Once it is
reached, new renders go to a freshly launched browser and the old one closes
after its in-flight renders finish, so recycling never interrupts a render.
renderPdf(html, options?) → Promise<Buffer>
renderPdfFromUrl(url, options?) → Promise<Buffer>
options are forwarded to Puppeteer's
page.pdf() (format, margin,
landscape, printBackground — defaults to true — …) plus:
| Option | Default | Description |
|---|---|---|
| timeout | 30000 | One deadline (ms) for the whole render, including a browser launch if one is needed. |
| waitUntil | pool's waitUntil | 'domcontentloaded', 'load', 'networkidle0' or 'networkidle2'. |
| assetTimeout | pool's assetTimeout | With 'domcontentloaded': ms to let images/fonts finish before cancelling them (see below). |
closeBrowser() → Promise<void>
Closes the pool's browser. Safe to call when it was never launched; the next render launches a new one. Intentional closes don't trigger the crash warning.
stats() → { active, queued }
Renders currently running and waiting — handy for health checks and metrics.
Errors
| err.code | When |
|---|---|
| ETIMEDOUT | Browser launch or page creation exceeded the timeout deadline. (Load and PDF steps throw Puppeteer's own TimeoutError.) |
| EQUEUEFULL | maxQueue renders were already waiting. |
Benchmarks
One-page A4 report (inline CSS, 30-row table), measured with
bench/benchmark.js on an Intel i7-6500U laptop
(2 cores / 4 threads, 8 GB RAM), Windows 10, Node 24, Puppeteer 25.3.
| Job | Mean | p50 | p95 |
|---|---|---|---|
| Launch Chromium + render + close, per PDF (no pool) | 1862 ms | 1862 ms | 2190 ms |
| Pool, first render (includes browser launch) | 1690 ms | | |
| Pool, warm render (isolate: true, default) | 657 ms | 625 ms | 885 ms |
| Pool, warm render (isolate: false) | 465 ms | 442 ms | 667 ms |
A warm render is ~2.8× faster than launching Chromium per PDF (~4× without isolation). Per-render isolation costs ~190 ms on this machine.
Burst of 40 simultaneous renders:
| concurrency | Wall time | PDFs/s |
|---|---|---|
| 1 | 28.3 s | 1.4 |
| 2 | 31.8 s | 1.3 |
| 4 | 24.8 s | 1.6 |
Rendering is CPU-bound, so on a 2-core machine extra concurrency barely helps.
Set concurrency to roughly the cores you can spare, and use maxQueue to cap
latency instead.
Other behaviour:
- Back-pressure (
concurrency: 2,maxQueue: 5, 20 simultaneous requests): 7 rendered, 13 rejected immediately withEQUEUEFULL. - Image host that never responds: 3.5 s with defaults (3 s
assetTimeout, then print without it); 1.0 s withassetTimeout: 500;waitUntil: 'networkidle0'fails withTimeoutErrorat thetimeout.
Numbers vary with hardware and OS (Chromium usually launches faster on Linux); run the benchmark on your own machine:
npm install --no-save puppeteer
node bench/benchmark.jsMemory under sustained load
bench/memory.js: 3,000 renders of a heavy page (400-row
table + SVG chart), concurrency 2, total memory of all Chromium processes
(same laptop, Windows):
| | Start | After 3,000 renders | Peak |
|---|---|---|---|
| One warm browser, no recycling | 367 MB | 375 MB | 377 MB |
| maxRendersPerBrowser: 500 | 360 MB | back to baseline at each recycle | 371 MB |
Closing each render's isolated context frees almost everything it used, so a
warm browser stays nearly flat. maxRendersPerBrowser is a safety valve for
pages that do grow Chromium's memory; every render succeeded across recycles.
node bench/memory.js [renders=3000] [maxRendersPerBrowser=500]Why domcontentloaded by default
HTML templates are expected to be self-contained (inline CSS). If a template
embeds a remote image and that host is slow or unreachable, waiting for
networkidle0 would hang the whole render — and the shared concurrency slot
— until timeout. With domcontentloaded, the pool gives images and fonts up to
assetTimeout (default 3000 ms) to finish, then cancels whatever is still
pending (window.stop()) and prints. A slow logo still makes it into the PDF;
a dead one renders blank after at most assetTimeout instead of stalling the
pool. (Without the cancel, Puppeteer's page.pdf() would keep waiting on it.)
If your content must be complete, set waitUntil: 'load' or
'networkidle0' and the render fails on timeout rather than printing
without it.
Multiple pools
Need independent concurrency budgets for different kinds of PDFs (e.g. certificates vs. large reports)? Create more than one pool — each gets its own browser instance and its own queue:
const certPool = createPdfRenderPool({ concurrency: 4 });
const reportPool = createPdfRenderPool({ concurrency: 1, maxQueue: 10 });Used in production
mcqplex.com uses pdf-render-pool to render exam
reports, student report cards and competition certificates. The package was
extracted from its PDF renderer.
License
MIT
