Build Your Own PDF API, Part 1: A Minimal HTML-to-PDF Service in 40 Lines
November 2, 2026
Part 1 of 6 of Build Your Own ReportGen.
- Part 1: A Minimal Service (you are here)
- Part 2: Templates, Page Numbers and Page Breaks
- Part 3: Async Jobs, Storage and Webhooks
- Part 4: Running Chrome in Production
- Part 5: Security
- Part 6: Docker, Hosting and Scaling
The core of every HTML-to-PDF service, including reportgen.io, is the same idea: open the HTML in a real browser and press "print to PDF". Headless Chrome does the layout, so anything that looks right in Chrome looks right in your PDF.
Let's build that in about 40 lines.
Why Headless Chrome?
There are other ways to make PDFs: wkhtmltopdf, PDF libraries like PDFKit, or LaTeX. We used Chrome because:
- Modern CSS works. Flexbox, grid, web fonts, SVG, CSS variables. wkhtmltopdf is stuck on an old WebKit and is no longer maintained.
- Designers can build templates. It's just HTML and CSS. No drawing coordinates by hand like with PDF libraries.
- Charts just work. Chart.js, ECharts and D3 render in the page before printing.
The cost: Chrome is heavy. Each open page takes real memory. We'll deal with that in Part 4.
Setup
mkdir pdf-service && cd pdf-service
npm init -y
npm install express puppeteernpm install puppeteer downloads a matching Chrome build automatically, so there's nothing else to install locally.
The Service
// server.js — the smallest useful HTML-to-PDF service
const express = require("express");
const puppeteer = require("puppeteer");
const app = express();
app.use(express.json({ limit: "5mb" }));
// Launch Chrome once and reuse it. Launching per request costs ~0.5–1s.
let browserPromise = puppeteer.launch({
headless: true,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
app.post("/pdf", async (req, res) => {
const { html, options = {} } = req.body;
if (typeof html !== "string" || html.length === 0) {
return res.status(400).json({ error: "html is required" });
}
const browser = await browserPromise;
const page = await browser.newPage();
try {
await page.setContent(html, { waitUntil: "networkidle0", timeout: 30_000 });
const pdf = await page.pdf({
format: options.format || "A4",
printBackground: true,
margin: options.margin || { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" },
});
res.type("application/pdf").send(Buffer.from(pdf));
} catch (err) {
console.error(err);
res.status(500).json({ error: "PDF generation failed" });
} finally {
await page.close(); // always close pages, or memory grows forever
}
});
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`listening on :${port}`));Run it:
node server.jsAnd try it:
curl -X POST localhost:3000/pdf \
-H 'content-type: application/json' \
-d '{"html":"<h1 style=\"color:#2563eb\">Hello PDF</h1><p>It works.</p>"}' \
-o hello.pdfOpen hello.pdf. That's a working PDF service.
The Lines That Matter
Most of that code is boilerplate. Five details are not:
1. One browser, many pages. puppeteer.launch() starts a whole Chrome process, which is slow. Launch once at startup, then open a new page (a tab) per request. Opening a page is fast.
2. page.close() in finally. If a render throws and you skip closing the page, that tab stays open forever. A few hundred leaked tabs later, the server runs out of memory. This is the most common bug in DIY PDF services.
3. waitUntil: "networkidle0". setContent returns when the HTML is parsed, but images, fonts and stylesheets may still be loading. networkidle0 waits until there have been no network requests for 500ms. Without it you get PDFs with missing logos.
4. printBackground: true. Chrome's print mode drops background colors and images by default, just like printing a web page on paper. Almost nobody wants that for a generated document.
5. --disable-dev-shm-usage. Docker gives containers a tiny 64MB /dev/shm by default, and Chrome uses it heavily. Without this flag Chrome crashes randomly inside containers. More in Part 6.
Calling It From Your App
const res = await fetch("http://pdf-service:3000/pdf", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ html: invoiceHtml }),
});
if (!res.ok) throw new Error(`PDF failed: ${res.status}`);
const pdf = Buffer.from(await res.arrayBuffer());Keep the service on your private network. As it stands, anyone who can reach it can make your server's browser load any URL, which is a serious problem we fix in Part 5. Don't expose it to the internet yet.
Is This Enough?
For a surprising number of apps, yes. If you:
- generate a modest number of PDFs,
- control the HTML yourself (it's not user-submitted),
- and users can wait a couple of seconds for a download,
then this service plus the templating from Part 2 may be all you ever need.
It stops being enough when:
- PDFs take long (big reports, many pages, slow charts) and HTTP requests start timing out → Part 3
- Traffic comes in bursts and too many open pages exhaust memory → Part 4
- Users can influence the HTML → Part 5
Frequently Asked Questions
Why not launch a new browser per request? It seems safer. It's isolated, but slow: each launch adds a noticeable delay and spikes CPU. Reusing one browser and recycling it periodically (Part 4) gets you most of the isolation at a fraction of the cost.
Can I render a URL instead of HTML?
Yes, swap page.setContent(html) for page.goto(url). But read Part 5 first: rendering arbitrary URLs is the easiest way to get your server hacked.
What about Playwright?
Playwright's page.pdf() works the same way and uses the same Chrome print engine. Everything in this guide translates almost line for line.
Next: Part 2: Templates, Page Numbers and Page Breaks. Raw HTML strings get messy fast, and multi-page documents have their own set of traps.