npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

Readme

pdf-render-pool

npm CI license

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 concurrency renders run at once (default 2); the rest queue FIFO.
  • Back-pressure — optionally cap the queue (maxQueue) so overload fails fast with EQUEUEFULL instead of piling up.
  • Bounded — every render has one timeout deadline 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 puppeteer

puppeteer 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 with EQUEUEFULL.
  • Image host that never responds: 3.5 s with defaults (3 s assetTimeout, then print without it); 1.0 s with assetTimeout: 500; waitUntil: 'networkidle0' fails with TimeoutError at the timeout.

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.js

Memory 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