Tutorial Mar 15, 2026

How to Generate PDFs from HTML in Node.js: wkhtmltopdf vs Puppeteer vs API

Generate PDFs in Node.js without wkhtmltopdf or Puppeteer. 5 lines of code with an HTTP API vs hours of infrastructure.

You need to generate PDFs in your Node.js app. Invoices, reports, certificates, contracts.

You search "Node.js HTML to PDF" and find two solutions:

  1. wkhtmltopdf — "Lightweight, easy to set up"
  2. Puppeteer — "Just use Puppeteer, it can do anything"

You pick one. Three days later, you're debugging font rendering, broken CSS, memory leaks, and system dependencies.

There's a simpler way. Let me show you all three approaches so you can decide which one actually works.

The wkhtmltopdf Approach

wkhtmltopdf is a command-line tool that converts HTML to PDF using WebKit. Here's the basic setup:

# Install wkhtmltopdf
apt-get install wkhtmltopdf

# In Node.js
npm install wkhtmltopdf
const wkhtmltopdf = require('wkhtmltopdf');
const fs = require('fs');

wkhtmltopdf('<h1>Hello World</h1>', (err, stream) => {
  if (err) return console.error(err);
  stream.pipe(fs.createWriteStream('invoice.pdf'));
});

Looks simple. But this is where pain begins.

1. System Dependencies

wkhtmltopdf requires a full WebKit rendering engine. On Linux, you need:

apt-get install wkhtmltopdf \
  fontconfig \
  fontconfig-config \
  fonts-liberation \
  fonts-noto-core \
  libjpeg-turbo-progs \
  libopenjp2-7 \
  libpng16-16 \
  libx11-6 \
  libxcb1 \
  libxext6 \
  libxrender1 \
  xfonts-encodings \
  xfonts-utils

Your Docker image bloats to 800MB just for PDF generation.

2. Font Rendering Issues

wkhtmltopdf doesn't render custom fonts well. Your beautiful design in the browser becomes a serif mess in the PDF.

// Your HTML has this:
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet">
<style>body { font-family: 'Inter', sans-serif; }</style>

// wkhtmltopdf renders it as Times New Roman

// Fix: install fonts locally and reference by path
<style>
  @font-face {
    font-family: 'Inter';
    src: url('file:///usr/share/fonts/opentype/inter/Inter-Regular.otf');
  }
</style>

3. CSS Support Issues

wkhtmltopdf doesn't support modern CSS. Flexbox works sometimes. CSS Grid doesn't work. Media queries sort of work. Your responsive HTML breaks when converted to PDF.

4. Memory & Concurrency

wkhtmltopdf spawns a WebKit process per PDF. Each process takes 100–200MB of RAM. With 10 simultaneous PDF requests, you need 1–2GB of RAM just for rendering. In production, you need a queue, rate limiting, or dedicated PDF servers.

The Puppeteer Approach

Puppeteer can generate PDFs, but it's overkill:

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setContent('<h1>Hello World</h1>');
  await page.pdf({ path: 'invoice.pdf' });
  await browser.close();
})();

This "works," but Puppeteer was designed for automation, not PDF generation. You're paying the full cost of Chrome for a single feature.

Puppeteer PDF problems:

For a service generating 1,000 PDFs/day, Puppeteer costs you a dedicated $400/month server just to avoid timeouts.

The API Approach

Here's PDF generation with an HTTP API:

async function generatePDF(htmlContent) {
  const response = await fetch('https://pagebolt.dev/api/v1/generate-pdf', {
    method: 'POST',
    headers: {
      'x-api-key': process.env.PAGEBOLT_API_KEY,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      html: htmlContent,
      format: 'A4',
      margin: '1cm'
    })
  });

  if (!response.ok) throw new Error(`PDF generation failed: ${response.status}`);
  return Buffer.from(await response.arrayBuffer());
}

13 lines including error handling. No system dependencies. No browser management.

Real-World Example: Invoice Generator

You're building a SaaS app that generates invoices as PDFs. Your customers request them on demand.

With wkhtmltopdf — a queue system, concurrency limits, file management: 60+ lines of infrastructure code.

With PageBolt API:

const express = require('express');

const app = express();
app.use(express.json());

app.post('/api/invoice/:id/pdf', async (req, res) => {
  const invoice = await db.invoices.findOne({ id: req.params.id });
  if (!invoice) return res.status(404).json({ error: 'Not found' });

  const html = renderInvoiceHTML(invoice);

  try {
    const response = await fetch('https://pagebolt.dev/api/v1/generate-pdf', {
      method: 'POST',
      headers: {
        'x-api-key': process.env.PAGEBOLT_API_KEY,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ html, format: 'A4', margin: '1cm' })
    });

    if (!response.ok) throw new Error('PDF generation failed');

    res.setHeader('Content-Type', 'application/pdf');
    res.send(Buffer.from(await response.arrayBuffer()));
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

app.listen(3000);

That's 28 lines. No queue. No memory management. No file storage. No infrastructure.

Feature Comparison

Feature wkhtmltopdf Puppeteer API
Setup time1 hour30 min5 min
System dependenciesHeavy (fonts, libs)Heavy (Chrome)None
Infrastructure cost$200/mo$400/mo$0–50/mo
PDF qualityGoodExcellentExcellent
Font supportLimitedGoodExcellent
CSS support~70%~95%100%
ConcurrencyLimited (~5)Limited (~10)Unlimited
Cold start latency100ms2–3s200ms
Memory per PDF50–100MB300–500MB~1MB

When to Use Each

Use wkhtmltopdf if:

Use Puppeteer if:

Use an API if:

Real-World Cost Comparison

At 10,000 PDFs/month:

Solution Infrastructure Ops Total
wkhtmltopdf$200$1,000$1,200
Puppeteer$400$1,500$1,900
PageBolt API$0$0$29

The API approach is 40–65x cheaper.

Production-Ready Example

const express = require('express');
require('dotenv').config();

const app = express();
app.use(express.json());

async function generatePDF(htmlContent, options = {}) {
  const response = await fetch('https://pagebolt.dev/api/v1/generate-pdf', {
    method: 'POST',
    headers: {
      'x-api-key': process.env.PAGEBOLT_API_KEY,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      html: htmlContent,
      format: options.format || 'A4',
      margin: options.margin || '1cm',
      landscape: options.landscape || false
    }),
    signal: AbortSignal.timeout(30000)
  });

  if (!response.ok) {
    throw new Error(`PDF generation failed: ${response.status} ${response.statusText}`);
  }

  return Buffer.from(await response.arrayBuffer());
}

app.post('/api/generate-pdf', async (req, res) => {
  const { html, format, margin } = req.body;

  if (!html) {
    return res.status(400).json({ error: 'HTML content required' });
  }

  try {
    const pdfBuffer = await generatePDF(html, { format, margin });
    res.setHeader('Content-Type', 'application/pdf');
    res.send(pdfBuffer);
  } catch (error) {
    console.error('PDF generation error:', error);
    res.status(500).json({ error: error.message });
  }
});

app.listen(3000, () => console.log('PDF server running on port 3000'));

Deploy this. It works. Move on to building your app.

Generate PDFs without infrastructure headaches

100 requests/month free. No wkhtmltopdf. No Chrome. No Docker bloat. Full CSS and font support.

Get free API key