DocAPI vs html-pdf: Why Node.js PDF Libraries Fall Short
If you search npm for "html pdf", the top result is html-pdf — 500k+ weekly downloads, 7k stars. The second is usually puppeteer-pdf or html-pdf-node. All of these feel like the right choice until you hit their limitations in production.
This guide explains what goes wrong and when a PDF API is the better call.
What html-pdf actually does
html-pdf is a wrapper around PhantomJS — a headless WebKit browser that was archived in 2018. It works by launching a PhantomJS binary, rendering your HTML, and capturing a PDF.
The problems:
- PhantomJS is abandoned. No security patches, no ES6+ support, no modern CSS. Flexbox is partially supported. CSS Grid is not.
- Binary distribution. The package ships platform-specific PhantomJS binaries. This inflates your
node_modulesand causes issues on ARM Macs, Docker containers, and serverless functions. - Doesn't run on serverless. PhantomJS requires a full OS environment. It cannot run in Vercel Edge Functions, Cloudflare Workers, or AWS Lambda without a custom layer.
- Inconsistent output. Because it renders with an outdated browser engine, your PDFs won't match what users see in Chrome or Safari.
html-pdf basic usage (and its limits)
const pdf = require('html-pdf')
const html = '<h1>Invoice #1234</h1><p>Amount: $500</p>'
const options = { format: 'A4' }
pdf.create(html, options).toBuffer((err, buffer) => {
if (err) return console.error(err)
// buffer is a PDF
})This works for simple HTML. Add a flexbox layout, a Google Font, or a background-color on a div, and results become unpredictable.
html-pdf-node: same idea, newer wrapper
html-pdf-node wraps Puppeteer (headless Chromium) instead of PhantomJS. Better rendering — but it brings the same binary distribution problem:
const htmlPdfNode = require('html-pdf-node')
const file = { content: '<h1>Invoice</h1>' }
const options = { format: 'A4' }
const pdfBuffer = await htmlPdfNode.generatePdf(file, options)Puppeteer downloads a Chromium binary on install (150–200 MB). On Lambda this exceeds the package limit. On CI it slows down build times. In Docker you need to install system dependencies (libnss3, libx11, etc.).
DocAPI: the API approach
Instead of bundling or downloading a browser, your code sends HTML to an endpoint that runs Chromium server-side:
const response = await fetch('https://api.docapi.co/v1/pdf', {
method: 'POST',
headers: {
'x-api-key': process.env.DOCAPI_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
html: '<h1>Invoice #1234</h1><p>Amount: $500</p>',
options: { format: 'A4', printBackground: true },
}),
})
const pdfBuffer = await response.arrayBuffer()No binary dependencies. No Chromium download. Works in Edge Functions, Lambda, and Workers.
Side-by-side comparison
| html-pdf | html-pdf-node | DocAPI | |
|---|---|---|---|
| Renderer | PhantomJS (2018) | Chromium | Chromium |
| CSS Grid/Flexbox | Partial | ✅ | ✅ |
| Web fonts | Limited | ✅ | ✅ |
| Install size | ~50 MB | ~200 MB | 0 MB |
| Serverless | ❌ | ❌ | ✅ |
| Maintenance | Archived | Active | Active |
| Pricing | Free | Free | Pay-per-use |
When to still use html-pdf-node
If your app runs on a long-running server (not serverless), your templates are simple, and you want zero external dependencies, html-pdf-node works. The main requirements:
- Docker or VM with sufficient memory (Chromium needs ~200 MB RAM per render)
- System dependencies installed (
chromium-browseror equivalent) - Not Lambda, Edge, or Workers
FROM node:20-slim
# Install Chromium dependencies for html-pdf-node
RUN apt-get update && apt-get install -y \
chromium \
libx11-xcb1 libxcomposite1 libxcursor1 libxdamage1 \
libxi6 libxtst6 libnss3 libcups2 libxss1 libxrandr2 \
libasound2 libpangocairo-1.0-0 libatk1.0-0 libatk-bridge2.0-0 \
libgtk-3-0 --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]This works but adds operational complexity: you own the Chromium installation, its security updates, and memory management.
Migration from html-pdf to DocAPI
If you're currently using html-pdf, the migration is a function swap:
Before:
const pdf = require('html-pdf')
async function generatePdf(html) {
return new Promise((resolve, reject) => {
pdf.create(html, { format: 'A4' }).toBuffer((err, buffer) => {
if (err) reject(err)
else resolve(buffer)
})
})
}After:
async function generatePdf(html) {
const response = await fetch('https://api.docapi.co/v1/pdf', {
method: 'POST',
headers: {
'x-api-key': process.env.DOCAPI_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
html,
options: { format: 'A4', printBackground: true },
}),
})
if (!response.ok) {
throw new Error(`PDF generation failed: ${response.status}`)
}
return Buffer.from(await response.arrayBuffer())
}The function signature is identical. Your existing code that calls generatePdf(html) doesn't change.
Using the Node.js SDK
If you prefer a typed client over raw fetch:
npm install @docapi/sdkimport { DocAPI } from '@docapi/sdk'
const docapi = new DocAPI({ apiKey: process.env.DOCAPI_KEY! })
const pdf = await docapi.pdf('<h1>Invoice</h1>', {
format: 'A4',
printBackground: true,
})
// pdf is a BufferFull options — margins, headers, footers, page size, landscape mode — match the Puppeteer page.pdf() API so existing Puppeteer users have a familiar interface.
Express.js example
import express from 'express'
import { DocAPI } from '@docapi/sdk'
const app = express()
const docapi = new DocAPI({ apiKey: process.env.DOCAPI_KEY! })
app.post('/invoices/:id/pdf', async (req, res) => {
const invoice = await getInvoice(req.params.id)
const pdf = await docapi.pdf(renderInvoiceHtml(invoice), {
format: 'A4',
printBackground: true,
margin: { top: '20mm', right: '15mm', bottom: '25mm', left: '15mm' },
})
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="invoice-${invoice.number}.pdf"`,
})
res.send(pdf)
})Get started
npm install @docapi/sdk# .env
DOCAPI_KEY=pk_your_key_hereGet a key at docapi.co/signup — 10 free PDFs, no credit card required.