PDF generation in Fastify works best with a shared headless Chromium browser launched once in a plugin, then reused across requests by a route that calls reply.type("application/pdf") and sends a Buffer. That warm pool is the difference between a 300ms render and a 700ms one. For simple fixed layouts, pdfkit draws PDFs with no browser at all. If you would rather not run Chromium on your own server, the hosted PDF4.dev API returns a PDF over HTTP. This guide covers all three with real Fastify code.
Which PDF approach fits your Fastify app?
The right choice depends on whether you need real HTML and CSS, and whether you want to run a browser on your own infrastructure. Browser engines (Playwright, Puppeteer) render any web layout but cost memory and cold-start time. Drawing libraries (pdfkit) are tiny and fast but you position every element yourself. A hosted API removes the engine from your server.
| Approach | Renders HTML/CSS | Per-render time (warm) | Memory cost | Best for |
|---|---|---|---|---|
| Playwright (warm plugin) | Yes, full Chromium | ~300ms | ~150MB browser | HTML/CSS documents, invoices, reports |
| Puppeteer (warm plugin) | Yes, full Chromium | ~300ms | ~150MB browser | Same as Playwright, older codebases |
| pdfkit | No, you draw | under 50ms | ~5MB | Fixed layouts, tickets, labels, no CSS |
| html-pdf-node | Yes (wraps Puppeteer) | ~350ms | ~150MB browser | Quick prototypes, less control |
| PDF4.dev (hosted) | Yes, full Chromium | one HTTP call | ~0 on your server | No infra, serverless, scaling out |
If your document is HTML and CSS, use a browser engine or a hosted API. If it is a fixed grid you can describe in code, pdfkit is smaller and faster. Do not reach for a browser to draw a shipping label.
How do you return a PDF buffer from a Fastify route?
Set the content type to application/pdf, add a Content-Disposition header, and pass the Buffer to reply.send. Fastify detects a Buffer payload and sends the raw bytes without running them through JSON serialization, so the file arrives uncorrupted. The two values that control browser behavior are the MIME type and the disposition (inline previews, attachment downloads).
// routes/pdf.ts
import type { FastifyInstance } from "fastify"
export async function pdfRoutes(app: FastifyInstance) {
app.get("/report.pdf", async (request, reply) => {
const buffer = await buildReportPdf() // returns a Buffer
reply
.type("application/pdf")
.header("Content-Disposition", 'attachment; filename="report.pdf"')
.header("Content-Length", buffer.length)
return reply.send(buffer)
})
}Use inline instead of attachment if you want the browser to display the PDF in a tab rather than trigger a download. Everything else stays the same.
How do you share one Playwright browser across Fastify requests?
Launch the browser once inside a fastify-plugin, decorate the Fastify instance with it, and close it in the onClose hook. This warm pool avoids paying the 200-500ms Chromium launch on every request. Each request then only opens a fresh page, sets HTML, calls page.pdf(), and closes the page. The browser process stays alive for the life of the server.
Install the pieces first:
npm install fastify fastify-plugin playwright
npx playwright install chromiumThe plugin decorates app.browser and registers cleanup. Using fastify-plugin is required so the decoration is visible to sibling plugins and routes, not trapped in an encapsulated scope.
// plugins/browser.ts
import fp from "fastify-plugin"
import { chromium, type Browser } from "playwright"
declare module "fastify" {
interface FastifyInstance {
browser: Browser
}
}
export default fp(async (app) => {
const browser = await chromium.launch({
args: ["--no-sandbox", "--disable-dev-shm-usage"],
})
app.decorate("browser", browser)
app.addHook("onClose", async () => {
await browser.close()
})
})The try/finally is the part people skip. If a render throws and you never call page.close(), that page leaks. After a few hundred leaked pages the Chromium process climbs past a gigabyte and the box starts swapping. Always close the page in finally.
--disable-dev-shm-usage matters in Docker. The default /dev/shm is 64MB, and Chromium will crash mid-render on larger pages without this flag. The --no-sandbox flag is needed for most container setups but never run untrusted HTML with it disabled.
How do you do the same with Puppeteer in Fastify?
Puppeteer follows the identical pattern: launch one browser in a fastify-plugin, decorate the instance, close on shutdown. The only differences are the import and the launch call. Puppeteer bundles its own Chromium download by default, so there is no separate install step like Playwright's.
npm install fastify fastify-plugin puppeteer// plugins/puppeteer.ts
import fp from "fastify-plugin"
import puppeteer, { type Browser } from "puppeteer"
declare module "fastify" {
interface FastifyInstance {
browser: Browser
}
}
export default fp(async (app) => {
const browser = await puppeteer.launch({
headless: true,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
})
app.decorate("browser", browser)
app.addHook("onClose", async () => {
await browser.close()
})
})Playwright and Puppeteer produce nearly identical PDFs because both drive Chromium's page.pdf(). Pick Playwright for newer projects (better multi-browser API, active development) and Puppeteer if it is already in your stack. Do not run both, you will ship two copies of Chromium.
When should you use pdfkit instead of a browser?
Use pdfkit when the document is a fixed layout you can describe with coordinates: tickets, labels, certificates, simple invoices. It draws PDFs directly with no Chromium, so a render finishes in under 50ms and the dependency is around 5MB instead of 150MB+. The tradeoff is that there is no HTML or CSS. You position every line, rectangle, and text run yourself.
npm install pdfkitpdfkit returns a readable stream, which pairs well with Fastify because you can send the stream straight to the client without buffering the whole file in memory.
// routes/ticket.ts
import type { FastifyInstance } from "fastify"
import PDFDocument from "pdfkit"
export async function ticketRoutes(app: FastifyInstance) {
app.get("/ticket.pdf", async (request, reply) => {
const doc = new PDFDocument({ size: "A4", margin: 50 })
reply
.type("application/pdf")
.header("Content-Disposition", 'attachment; filename="ticket.pdf"')
// doc is a Readable stream; Fastify pipes it to the response
reply.send(doc)
doc.fontSize(24).text("Event ticket", { align: "center" })
doc.moveDown()
doc.fontSize(12).text("Admit one")
doc.rect(50, 200, 495, 100).stroke()
doc.end()
})
}The moment you find yourself reimplementing flexbox with doc.rect() calls, stop and switch to a browser engine or a hosted API. pdfkit is the right tool for a boarding pass, the wrong tool for a styled marketing report.
What about serverless Fastify on Lambda or Vercel?
Bundling Chromium is the hard part on serverless. The full Playwright or Puppeteer Chromium download is around 280MB unzipped, over the AWS Lambda 250MB unzipped code limit. The fix is a slimmed build like @sparticuz/chromium paired with puppeteer-core or playwright-core, which strips the bundled browser and points at the Lambda layer instead.
npm install puppeteer-core @sparticuz/chromium// serverless handler fragment
import chromium from "@sparticuz/chromium"
import puppeteer from "puppeteer-core"
const browser = await puppeteer.launch({
args: chromium.args,
executablePath: await chromium.executablePath(),
headless: true,
})Even with the slim build, expect cold starts of 2-5 seconds while the function loads and Chromium spins up. On a warm Fastify instance behind a long-lived server, the plugin pattern above keeps the browser hot and avoids this entirely. Serverless is where a hosted API pays off most: you trade a fragile bundling step and multi-second cold starts for a single HTTP request.
If you only need to convert HTML to a PDF once in a while, our free Html To PdfTry it free does it in the browser with no setup. The Webpage To PdfTry it free tool captures a live URL.
How do you generate PDFs without running a browser at all?
Call a hosted API that renders the HTML for you and returns the PDF. PDF4.dev runs headless Chromium server-side, so your Fastify app makes one HTTP request and never installs Playwright, never fights a serverless Chromium bundle, and never holds 150MB of browser in memory. This is the "no infrastructure" row in the table near the top.
The minimal request sends HTML and asks for a URL back:
curl -X POST https://pdf4.dev/api/v1/render \
-H "Authorization: Bearer p4_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"html": "<h1>Hello from Fastify</h1>",
"data": {},
"delivery": "url"
}'From a Fastify route, call it with Node's built-in fetch (or undici, which Fastify already depends on). Handlebars {{variables}} in the template are filled from the data object server-side.
// routes/hosted.ts
import type { FastifyInstance } from "fastify"
export async function hostedRoutes(app: FastifyInstance) {
app.post("/invoice.pdf", async (request, reply) => {
const res = await fetch("https://pdf4.dev/api/v1/render", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PDF4_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html: "<h1>Invoice {{invoice_number}}</h1><p>Total: {{total}}</p>",
data: { invoice_number: "INV-001", total: "$1,500.00" },
delivery: "base64",
}),
})
const { pdf_base64 } = await res.json()
const buffer = Buffer.from(pdf_base64, "base64")
reply.type("application/pdf")
return reply.send(buffer)
})
}Use delivery: "url" for large PDFs: the response is a signed URL valid for 24 hours instead of a base64 blob, which keeps your Fastify response small. Use delivery: "base64" when you want to stream the bytes straight back through your own route, as the first tab shows.
Which option should you choose?
Match the approach to your document and your infrastructure. There is no single winner: a shipping label and a styled invoice want different tools.
| Scenario | Recommended approach |
|---|---|
| HTML/CSS documents, own server | Playwright in a warm fastify-plugin |
| Existing Puppeteer codebase | Puppeteer plugin, same pattern |
| Fixed layout, tickets, labels | pdfkit, stream straight to reply |
| Serverless (Lambda, Vercel) | Hosted API, or @sparticuz/chromium if you must self-host |
| No browser ops, fast to ship | PDF4.dev hosted API |
| High volume, want to scale out | Hosted API removes Chromium memory from your boxes |
- Run Playwright in a plugin if you own the server, render HTML and CSS, and want full control. Keep the browser warm, close pages in
finally, set the Docker flags. - Use pdfkit when the layout is fixed and you do not need CSS. It is 30x smaller and finishes in under 50ms.
- Call a hosted API when you are on serverless, when Chromium memory is hurting you, or when you would simply rather not own a browser. PDF4.dev renders the HTML and hands back a PDF over one HTTP call.
Start with the hosted API to ship today, then move to a self-hosted Playwright plugin later if volume or cost makes it worthwhile. The HTML template you write does not change between the two, only where Chromium runs.
Whichever you pick, the Fastify side is the same three lines: set reply.type("application/pdf"), set a Content-Disposition header, and reply.send(buffer). Get the Buffer from a warm browser, from pdfkit, or from an HTTP call, and Fastify ships the bytes.
Free tools mentioned:
Start generating PDFs
Build PDF templates with a visual editor. Render them via API from any language in ~300ms.



