Puppeteer PDF in Production: Chromium, Memory, Concurrency and Serverless
A production guide to Puppeteer PDF generation: understand Chromium processes, reuse browsers safely, cap concurrency, recover from crashes, package fonts, and deploy to Docker, Lambda or Vercel.
On this page
Puppeteer makes the first PDF wonderfully small: launch a browser, load HTML, call page.pdf(), and close the browser. That same code can become unreliable in production when several requests launch Chrome together, a renderer runs out of memory, a font is missing, or a serverless deployment cannot find the expected browser binary.
This guide explains how to run Puppeteer PDF generation in production without pretending that Chromium is a normal Node.js dependency. You will see how to reuse a browser without leaking pages, isolate jobs with browser contexts, cap concurrency, survive crashes, package fonts, build queues, and make deliberate choices for Docker, AWS Lambda and Vercel.
Reuse browser startup when it helps, create a context per job, and deliberately recycle long-lived processes.
A shared browser is not unlimited. Admit only measured work and let the queue absorb bursts.
Node heap alone misses Chrome. Track container RSS, render latency, failures, queue depth and browser restarts.
Short answer: launch one browser per application instance, create an isolated browser context for each PDF, close its page and context in
finally, and put a measured concurrency gate in front of the browser. Treat disconnections and timeouts as expected failures, keep jobs idempotent, package fonts with the deployment, run Chrome with a working sandbox, and measure the entire container—not only Node.js heap.
What Puppeteer PDF generation actually starts#
The happy-path API looks like one JavaScript operation:
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.setContent(html, {
waitUntil: 'networkidle0',
});
await page.pdf({
path: 'invoice.pdf',
format: 'A4',
printBackground: true,
});
} finally {
await browser.close();
}
Installing puppeteer downloads a compatible Chrome for Testing and a headless shell by default. puppeteer-core does not download a browser; use it when your platform supplies Chrome or when you connect to a remote browser, and provide an executable or channel explicitly.
At runtime, the control path is larger than the JavaScript snippet suggests:
That browser needs an executable, compatible shared libraries, fonts, a writable profile and temporary directory, permission to create child processes, a sandbox strategy, CPU, memory, file descriptors and time to finish. page.pdf() is the final operation in a small browser platform.
browser.launch() is a deployment decision#
Launching Chrome for every document is a reasonable starting point. Each job gets a clean browser and cleanup is easy to understand. The cost is repeated process startup, profile initialization and font discovery.
Reusing one browser avoids that repeated startup, but it moves responsibility into your application: failed pages must be closed, state must be isolated, disconnected browsers must be replaced, and long-lived processes need a recycling policy.
Launch → render → close
Simple ownership and clean state, with browser startup paid for every document.
Launch → isolate many → recycle
Lower startup overhead, but health, isolation, cleanup and capacity become application concerns.
Do not choose reuse because a benchmark says it is always faster. Measure your documents and deployment. A short receipt rendered occasionally may not justify a pool. A worker producing thousands of reports usually should not launch a completely new browser for every row.
Reuse one browser, isolate each PDF#
Puppeteer’s BrowserContext provides isolated cookies and cache. Closing a context closes its pages, which makes one context per render job a useful ownership boundary.
import puppeteer from 'puppeteer';
let browserPromise;
export async function getBrowser() {
if (!browserPromise) {
browserPromise = puppeteer.launch({
headless: true,
});
browserPromise
.then((browser) => {
browser.on('disconnected', () => {
browserPromise = undefined;
});
})
.catch(() => {
browserPromise = undefined;
});
}
const browser = await browserPromise;
if (!browser.connected) {
browserPromise = undefined;
return getBrowser();
}
return browser;
}
The render owns a context and closes it even when navigation, JavaScript or PDF creation fails:
import { getBrowser } from './browser-runtime.mjs';
export async function renderPdf(html) {
const browser = await getBrowser();
const context = await browser.createBrowserContext();
try {
const page = await context.newPage();
await page.setContent(html, {
waitUntil: 'networkidle0',
timeout: 20_000,
});
return await page.pdf({
format: 'A4',
printBackground: true,
preferCSSPageSize: true,
});
} finally {
await context.close().catch(() => {});
}
}
A context is a state-isolation boundary, not a memory quota or security sandbox. Documents still share the browser process and host resources. If you render untrusted documents or need stronger tenant isolation, use separate browser processes, containers or a rendering service rather than assuming a context contains every failure.
Pages, contexts and Chromium memory#
Chrome uses multiple processes. The browser coordinates renderer, network and utility processes, and the exact tree changes with document content, site isolation, Chrome version and flags.
process.memoryUsage() reports the Node.js process. It does not describe the complete Chrome process tree. page.metrics() exposes useful page-level counters, but it is not a replacement for measuring container or host capacity.
For production sizing, record at least:
- total container or cgroup RSS, not only V8 heap;
- active pages and contexts;
- successful PDFs per second and render duration percentiles;
- browser launches, disconnects, restarts and failed jobs;
- queue depth and oldest-job age;
- OOM kills, process count, open file descriptors and temporary disk use;
- document family, page count and output size—without logging sensitive HTML.
Do not publish a universal “Puppeteer uses X MB per PDF” value. Renderers share browser resources, documents vary enormously, and concurrent work changes the boundary. Capacity-test your heaviest representative invoice, report and certificate on the same CPU and memory limits used in production.
Concurrency needs a gate, not Promise.all()#
Browser reuse and browser concurrency are separate decisions. One browser can serve several pages, but Promise.all(invoices.map(renderPdf)) admits the entire batch immediately. A burst of 500 jobs can create hundreds of contexts before the first PDF finishes.
Use a queue or semaphore in front of the browser. This dependency-light example keeps at most four render functions active inside one process:
export class RenderPool {
#active = 0;
#waiting = [];
constructor(limit) {
if (!Number.isInteger(limit) || limit < 1) {
throw new TypeError('limit must be a positive integer');
}
this.limit = limit;
}
async run(task) {
if (this.#active >= this.limit) {
await new Promise((resolve) => this.#waiting.push(resolve));
}
this.#active += 1;
try {
return await task();
} finally {
this.#active -= 1;
this.#waiting.shift()?.();
}
}
}
export const pdfPool = new RenderPool(4);
import { pdfPool } from './render-pool.mjs';
import { renderPdf } from './render-in-context.mjs';
const results = await Promise.all(
invoices.map((invoice) =>
pdfPool.run(() => renderPdf(renderInvoice(invoice))),
),
);
The value 4 is an example, not a recommendation. Increase the limit in steps and stop when throughput flattens or p95 latency, RSS and failures rise disproportionately. If you run five application instances, a local limit of four permits twenty simultaneous renders; capacity is the product of every replica, worker and browser pool.
For large batches, do not keep thousands of unresolved promises in memory. Pull jobs from a durable queue only when the local gate has room, acknowledge after the PDF reaches durable storage, and let queue depth provide back pressure.
A queue solves delivery, not browser capacity#
A production queue is still the right place for long reports, exports, emails and batch PDFs. It makes retries visible, separates web latency from rendering, and allows PDF workers to use different resource limits.
The queue does not create RAM or CPU. Twenty consumers can launch twenty browsers just as easily as twenty HTTP requests. Configure a global or per-instance capacity limit, keep job timeout longer than the browser operation timeout, and make the output key idempotent so a retry cannot create conflicting records.
A useful retry policy distinguishes failures:
| Failure | Retry? | Response |
|---|---|---|
| Chromium disconnected or renderer crashed | Usually | Replace or recycle the browser; retry with backoff |
| Temporary remote asset timeout | Sometimes | Retry, then remove or cache the remote dependency |
| Deterministic template/JavaScript error | No | Record the document reference and fix the template |
| Host out of memory | Not immediately | Reduce admission, restore capacity, then retry |
| Missing binary, library or font | No | Fail a deployment health check before taking jobs |
Recover from crashes, hangs and slow leaks#
A long-lived browser will eventually encounter a document or environment failure. Design for replacement instead of trying to make one process immortal.
- Listen for the browser
disconnectedevent and invalidate the shared reference. - Put a finite timeout around navigation, readiness and the complete job.
- Always close the job context in
finally. - Recycle the browser after a measured job count, age, RSS threshold or suspicious failure—not on an arbitrary interval alone.
- Retry only idempotent work, with bounded attempts and backoff.
- Stop admitting new pages before graceful worker shutdown, then allow active contexts to finish within a deadline.
- Run a readiness check that launches or connects to Chrome and renders a representative one-page PDF after every deployment.
If a timeout rejects your application promise but leaves a page running, the workload has not actually stopped. Ensure cancellation closes the context or kills and replaces the browser when it cannot prove the job is gone.
Fonts, images and print CSS are part of the render#
page.setContent(html) does not invent a public origin for root-relative URLs such as /images/logo.svg. Make every asset resolvable from the browser’s real boundary:
- use absolute HTTPS URLs reachable from the browser;
- serve assets from an authenticated or short-lived internal endpoint;
- embed small assets as data URLs;
- or intercept requests and respond from an approved local asset map.
Avoid giving arbitrary document HTML unrestricted file:// access. It makes a convenient local image path compete with a much larger filesystem security boundary.
Fonts are equally operational. Package the exact files and weights, define them with @font-face, and wait for the browser font set before printing:
await page.setContent(html, {
waitUntil: 'networkidle0',
});
await page.evaluate(async () => {
await document.fonts.ready;
});
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
preferCSSPageSize: true,
});
page.pdf() uses print CSS by default. Call page.emulateMediaType('screen') before printing only when the document is intentionally designed around screen styles. For precise brand colors, Puppeteer documents -webkit-print-color-adjust: exact; for layout, prefer explicit @page, physical units and deliberate page-break rules.
Do not rely on networkidle0 alone when a chart library schedules work after the network becomes idle. Let the template set a deterministic signal such as window.__PDF_READY__ = true, wait for that expression with a timeout, and then wait for fonts.
Running Puppeteer PDF in Docker#
Docker makes the browser image repeatable, but it does not remove browser operations. Pin Puppeteer and the image, install the matching fonts, run as a non-root user, preserve a working Chrome sandbox, and use an init process to reap child processes.
Puppeteer publishes an official Docker image containing Chrome, dependencies and the matching Puppeteer version. Its documented sandbox setup needs the required container capability. Copying --no-sandbox from a random snippet is not a neutral compatibility fix: Puppeteer’s own troubleshooting guide strongly discourages running without a sandbox.
Also measure /dev/shm, temporary disk, PID and file-descriptor limits under parallel load. A container that renders one invoice in a smoke test can still fail when eight pages decode images and fonts simultaneously.
Puppeteer PDF on AWS Lambda#
Lambda changes browser packaging and capacity assumptions:
- the standard
puppeteerinstall includes browser downloads that may not fit the packaging path you chose; puppeteer-coreplus a Lambda-compatible Chromium package or a container image is a common pattern;- writable storage is ephemeral
/tmpand must be cleaned or reused deliberately; - CPU allocation increases with configured memory, so memory sizing also changes render speed;
- warm environment reuse is an optimization, not a guarantee—validate that a cached browser is still connected;
- one invocation may hit file-descriptor, thread or process limits before it hits JavaScript heap limits.
AWS currently allows Lambda memory from 128 MB to 10,240 MB, with CPU proportional to memory; approximately 1,769 MB corresponds to one vCPU. Zip deployments have a 250 MB uncompressed limit including layers, container images can be up to 10 GB, /tmp can be configured from 512 MB to 10,240 MB, and a function can run for up to 900 seconds. Verify the current Lambda quotas for your deployment rather than copying a historical browser recipe.
None of those maximums tells you the correct configuration. Load-test the real PDF, watch Max Memory Used, p95 duration, cold starts and failure rate, then leave headroom. A memory setting that barely survives one render is not capacity for concurrent renders.
Puppeteer PDF on Vercel#
Vercel’s official Puppeteer deployment guide uses puppeteer-core with a serverless-compatible Chromium package. That keeps the browser choice explicit rather than depending on the full Puppeteer download.
The platform is evolving, so avoid hard-coding old folklore. Vercel Functions now use Fluid compute for supported workloads, and eligible Node.js deployments can opt into longer execution and larger function bundles. The browser still consumes function memory and CPU, still needs a compatible executable, and still adds cold-start and packaging work. Read the current Functions documentation and limits for the plan and runtime you deploy.
Do not assume a module-level browser singleton means one global browser for the entire project. It can only reuse a warm function instance. Multiple instances may exist, scale independently and serve concurrent invocations; your admission limit and cleanup must match that execution model.
Security: PDF HTML still controls a browser#
The input is not “just HTML.” Chromium can execute JavaScript and request URLs. If templates or data are untrusted, a render may attempt to reach cloud metadata, internal services, loopback addresses or oversized resources.
Define the trust boundary before launch:
- keep Chrome’s sandbox enabled and run the container as a non-root user;
- restrict outbound network access, DNS and private address ranges where practical;
- intercept requests and allow only the schemes, hosts and resource types the document needs;
- never expose a remote debugging endpoint to an untrusted network;
- disable or tightly control JavaScript when the document does not require it;
- limit HTML, asset, response, time, page and memory size;
- escape untrusted template values and keep secrets out of the DOM;
- isolate tenants more strongly than a browser context when the threat model requires it.
Request interception is useful defense in depth, but application-level URL string checks are not a complete SSRF boundary. Redirects, DNS resolution and browser behavior still matter. Prefer network controls at the container or platform layer for sensitive environments.
An observable production design#
A dependable deployment separates admission, execution and delivery:
API → job reference
Validate size, authorize and create an idempotent document job.Durable back pressure
Hold bursts without opening browser pages prematurely.Gate → context → PDF
Bound concurrency and close every job-owned resource.Store → notify
Persist output before acknowledging the job or sending a webhook.Log a document reference, attempt, queue wait, browser generation, render duration, output size and failure category. Do not log document HTML, credentials or signed asset URLs. Alerts should distinguish a template-specific failure from platform-wide browser disconnects, rising queue age or container OOM events.
Before increasing replicas or render slots, test the full path with several document families. Find the point where successful throughput stops rising cleanly. Operate below it, with enough memory and CPU headroom for a slow image decode or unusually long report.
When Puppeteer is the right PDF renderer#
Keep Puppeteer when:
- PDF creation depends on custom navigation, authentication, request interception or page scripting;
- the same browser runtime also handles screenshots, scraping, tests or automation;
- rendering must work offline or inside a network that cannot call an external service;
- documents must remain entirely inside infrastructure you operate;
- your team already operates browser workloads and values exact Chrome/CDP control.
Puppeteer is excellent at browser automation. The production work described here is not evidence that it is a poor library; it is the consequence of choosing to own a powerful, general browser runtime.
When to move PDF generation behind an API#
Use a managed PDF API when your application already has the HTML, but browser installation, capacity and recovery do not differentiate your product.
The boundary becomes explicit:
| Responsibility | Puppeteer in your app | BladePDF Node SDK |
|---|---|---|
| Render the application template | Your Node.js code | Your Node.js code |
| Build PDF options | Puppeteer call | Typed SDK builder |
| Package and launch Chromium | Your deployment | BladePDF |
| Limit pages and recover browsers | Your workers | BladePDF |
| Return or store the PDF | Your code and infrastructure | Buffer, stream, file or managed async result |
| Process document HTML/assets | Your infrastructure | BladePDF service |
That last row is the central tradeoff. A managed service creates a network and data-processing boundary. Puppeteer remains the better choice when that boundary is unacceptable or when you need general browser control.
For a focused PDF workload, the code can become:
import { BladePdf } from '@bladepdf/node';
const apiKey = process.env.BLADEPDF_API_KEY;
if (!apiKey) throw new Error('Missing BLADEPDF_API_KEY');
const bladePdf = new BladePdf({ apiKey });
await bladePdf
.fromHtml(html)
.format('A4')
.printBackground()
.renderToFile('invoice.pdf');
With Puppeteer, you operate Chromium. With the BladePDF Node SDK, you send HTML and BladePDF operates Chromium. Compare the complete Puppeteer alternative for PDF generation, or follow the practical Node.js PDF guide for Express, Fastify, Next.js, EJS and React examples.
Continue reading
Send HTML from Node.js. Let BladePDF operate the browser.
Keep EJS, React, Handlebars or your current HTML. The official Node.js SDK returns a Buffer, stream, file or stored background result without shipping Chrome in your application.
Was this article helpful?
Your answer helps shape future technical guides.