TL;DR
pdf-lib is the modern choice for 2026 — pure TypeScript, no native dependencies, works in Node.js and browsers, and gives you full programmatic control over PDF structure. jsPDF is the oldest and most-downloaded option, best for quick HTML-to-PDF scenarios. pdfmake is ideal for document-definition-based reports where non-developers define templates — think invoices, contracts, and multi-page formatted documents.
Key Takeaways
- pdf-lib: ~430K weekly downloads — TypeScript-native, modifies existing PDFs + creates new ones, no native deps
- jsPDF: ~900K weekly downloads — oldest library, canvas-like API, excellent HTML capture via html2canvas
- pdfmake: ~410K weekly downloads — document definition object model, table/column layouts, server + browser
- Use pdf-lib when creating PDFs programmatically with precise control
- Use jsPDF for HTML → PDF conversion or quick document generation
- Use pdfmake for report-style PDFs driven by structured data templates
Download Trends
| Package | Weekly Downloads | Approach | Browser? | Modify existing? |
|---|---|---|---|---|
jspdf | ~900K | Canvas-like API | ✅ | ⚠️ Limited |
pdf-lib | ~430K | Low-level PDF ops | ✅ | ✅ |
pdfmake | ~410K | Document definition | ✅ | ❌ |
pdf-lib
pdf-lib is a modern PDF library that can create PDFs from scratch OR modify existing PDFs. Pure TypeScript, no native dependencies, runs anywhere JavaScript runs.
Creating a PDF from Scratch
import { PDFDocument, StandardFonts, rgb, degrees, PageSizes } from "pdf-lib"
import { readFileSync, writeFileSync } from "fs"
async function createPackageReport(packages: PackageData[]) {
const pdfDoc = await PDFDocument.create()
// Embed standard fonts (no external font files needed):
const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica)
const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold)
// Add a page (A4):
const page = pdfDoc.addPage(PageSizes.A4)
const { width, height } = page.getSize()
// Draw header background:
page.drawRectangle({
x: 0,
y: height - 80,
width,
height: 80,
color: rgb(0.18, 0.42, 0.31), // #2D6A4F
})
// Title:
page.drawText("Package Download Report", {
x: 40,
y: height - 50,
size: 24,
font: helveticaBold,
color: rgb(1, 1, 1),
})
// Subtitle:
page.drawText(`Generated ${new Date().toLocaleDateString()}`, {
x: 40,
y: height - 70,
size: 12,
font: helvetica,
color: rgb(0.9, 0.9, 0.9),
})
// Draw table:
const tableTop = height - 120
const rowHeight = 30
const colWidths = [200, 150, 100, 80]
const colX = [40, 240, 390, 490]
// Header row:
const headers = ["Package", "Weekly Downloads", "Version", "License"]
headers.forEach((header, i) => {
page.drawRectangle({
x: colX[i],
y: tableTop - rowHeight,
width: colWidths[i],
height: rowHeight,
color: rgb(0.95, 0.95, 0.95),
})
page.drawText(header, {
x: colX[i] + 8,
y: tableTop - rowHeight + 10,
size: 10,
font: helveticaBold,
color: rgb(0, 0, 0),
})
})
// Data rows:
packages.forEach((pkg, rowIndex) => {
const y = tableTop - (rowIndex + 2) * rowHeight
// Zebra striping:
if (rowIndex % 2 === 0) {
page.drawRectangle({
x: 40,
y,
width: width - 80,
height: rowHeight,
color: rgb(0.98, 0.98, 0.98),
})
}
const values = [
pkg.name,
pkg.weeklyDownloads.toLocaleString(),
pkg.version,
pkg.license,
]
values.forEach((value, colIndex) => {
page.drawText(value, {
x: colX[colIndex] + 8,
y: y + 10,
size: 9,
font: helvetica,
color: rgb(0.2, 0.2, 0.2),
})
})
})
// Footer:
page.drawText("PkgPulse.com — Package ecosystem analytics", {
x: 40,
y: 20,
size: 9,
font: helvetica,
color: rgb(0.5, 0.5, 0.5),
})
const pdfBytes = await pdfDoc.save()
return pdfBytes
}
Modifying an Existing PDF
The feature that distinguishes pdf-lib from competitors:
import { PDFDocument, rgb } from "pdf-lib"
async function addWatermarkToPdf(inputPdfBytes: Uint8Array) {
const pdfDoc = await PDFDocument.load(inputPdfBytes)
const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica)
const pages = pdfDoc.getPages()
for (const page of pages) {
const { width, height } = page.getSize()
// Add diagonal watermark:
page.drawText("CONFIDENTIAL", {
x: width / 4,
y: height / 2,
size: 60,
font: helvetica,
color: rgb(0.9, 0.1, 0.1),
opacity: 0.2,
rotate: degrees(45),
})
}
return pdfDoc.save()
}
// Merge multiple PDFs:
async function mergePdfs(pdfs: Uint8Array[]) {
const mergedPdf = await PDFDocument.create()
for (const pdfBytes of pdfs) {
const pdf = await PDFDocument.load(pdfBytes)
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices())
copiedPages.forEach((page) => mergedPdf.addPage(page))
}
return mergedPdf.save()
}
// Extract pages:
async function extractPages(inputPdfBytes: Uint8Array, pageIndices: number[]) {
const pdfDoc = await PDFDocument.load(inputPdfBytes)
const newDoc = await PDFDocument.create()
const pages = await newDoc.copyPages(pdfDoc, pageIndices)
pages.forEach((page) => newDoc.addPage(page))
return newDoc.save()
}
pdf-lib with custom fonts:
import { PDFDocument, PDFFont } from "pdf-lib"
import fontkit from "@pdf-lib/fontkit"
import { readFileSync } from "fs"
const pdfDoc = await PDFDocument.create()
pdfDoc.registerFontkit(fontkit)
// Embed TTF font:
const interBytes = readFileSync("./fonts/Inter-Regular.ttf")
const interFont = await pdfDoc.embedFont(interBytes)
// Now use interFont in drawText calls
jsPDF
jsPDF is the most widely downloaded PDF library — built for browser-first use cases and HTML-to-PDF capture.
import jsPDF from "jspdf"
import html2canvas from "html2canvas"
// Basic document creation:
function createSimpleReport(packages: PackageData[]) {
const doc = new jsPDF({
orientation: "portrait",
unit: "mm",
format: "a4",
})
// Title:
doc.setFontSize(22)
doc.setFont("helvetica", "bold")
doc.text("Package Report", 20, 30)
// Subheading:
doc.setFontSize(12)
doc.setFont("helvetica", "normal")
doc.setTextColor(100, 100, 100)
doc.text(`Generated ${new Date().toLocaleDateString()}`, 20, 42)
// Line separator:
doc.setDrawColor(200, 200, 200)
doc.line(20, 48, 190, 48)
// Table header:
doc.setFillColor(45, 106, 79)
doc.rect(20, 54, 170, 10, "F")
doc.setTextColor(255, 255, 255)
doc.setFontSize(10)
doc.text("Package", 24, 61)
doc.text("Downloads/wk", 90, 61)
doc.text("Version", 145, 61)
// Table rows:
let y = 74
packages.forEach((pkg, i) => {
if (i % 2 === 0) {
doc.setFillColor(248, 248, 248)
doc.rect(20, y - 6, 170, 10, "F")
}
doc.setTextColor(30, 30, 30)
doc.setFontSize(9)
doc.text(pkg.name, 24, y)
doc.text(pkg.weeklyDownloads.toLocaleString(), 90, y)
doc.text(pkg.version, 145, y)
y += 12
// New page if needed:
if (y > 270) {
doc.addPage()
y = 30
}
})
// Save or return as blob:
doc.save("packages-report.pdf")
// Or: return doc.output("blob") / doc.output("arraybuffer")
}
jsPDF + html2canvas — the killer combo:
// Capture any DOM element as PDF:
async function exportDashboardAsPdf(elementId: string) {
const element = document.getElementById(elementId)!
// Render DOM to canvas:
const canvas = await html2canvas(element, {
scale: 2, // 2x resolution for crisp PDF
useCORS: true, // Allow cross-origin images
logging: false,
})
const imgData = canvas.toDataURL("image/png")
const pdf = new jsPDF({
orientation: canvas.width > canvas.height ? "landscape" : "portrait",
unit: "px",
format: [canvas.width / 2, canvas.height / 2],
})
pdf.addImage(imgData, "PNG", 0, 0, canvas.width / 2, canvas.height / 2)
pdf.save("dashboard.pdf")
}
jsPDF limitations:
- Canvas-based API — no notion of reflow, pages, or document structure
- HTML-to-PDF via html2canvas is a screenshot, not a real PDF render
- Text selection and copy/paste don't work on html2canvas-captured PDFs
- No support for modifying existing PDFs
pdfmake
pdfmake uses a document definition object — you describe what the document should contain (tables, lists, columns, text) and pdfmake handles layout.
import pdfMake from "pdfmake/build/pdfmake"
import pdfFonts from "pdfmake/build/vfs_fonts"
pdfMake.vfs = pdfFonts.pdfMake.vfs
function createInvoice(order: OrderData) {
const docDefinition = {
pageSize: "A4",
pageMargins: [40, 60, 40, 60],
// Header (appears on every page):
header: {
columns: [
{ text: "PkgPulse", style: "headerLogo" },
{ text: `Invoice #${order.id}`, style: "headerRight", alignment: "right" },
],
margin: [40, 20, 40, 20],
},
// Footer with page numbers:
footer: (currentPage: number, pageCount: number) => ({
text: `Page ${currentPage} of ${pageCount}`,
alignment: "center",
style: "footer",
}),
content: [
// Title section:
{
columns: [
{
stack: [
{ text: "INVOICE", style: "invoiceTitle" },
{ text: `Date: ${new Date(order.date).toLocaleDateString()}`, style: "meta" },
{ text: `Due: ${new Date(order.dueDate).toLocaleDateString()}`, style: "meta" },
],
},
{
stack: [
{ text: "Bill To:", style: "label" },
{ text: order.customer.name, style: "customerName" },
{ text: order.customer.email, style: "meta" },
{ text: order.customer.address, style: "meta" },
],
alignment: "right",
},
],
},
// Spacer:
{ text: "", margin: [0, 20, 0, 0] },
// Items table:
{
table: {
headerRows: 1,
widths: ["*", 80, 80, 80],
body: [
// Header row:
[
{ text: "Description", style: "tableHeader" },
{ text: "Qty", style: "tableHeader", alignment: "center" },
{ text: "Unit Price", style: "tableHeader", alignment: "right" },
{ text: "Total", style: "tableHeader", alignment: "right" },
],
// Data rows:
...order.items.map((item) => [
{ text: item.description },
{ text: item.quantity.toString(), alignment: "center" },
{ text: `$${item.unitPrice.toFixed(2)}`, alignment: "right" },
{ text: `$${(item.quantity * item.unitPrice).toFixed(2)}`, alignment: "right" },
]),
],
},
layout: "lightHorizontalLines",
},
// Totals:
{
columns: [
{ text: "" }, // Empty left column
{
table: {
widths: [100, 80],
body: [
["Subtotal:", `$${order.subtotal.toFixed(2)}`],
["Tax (10%):", `$${order.tax.toFixed(2)}`],
[{ text: "Total:", bold: true }, { text: `$${order.total.toFixed(2)}`, bold: true }],
],
},
layout: "noBorders",
alignment: "right",
},
],
margin: [0, 20, 0, 0],
},
// Payment instructions:
{
text: "Payment Instructions",
style: "sectionHeader",
margin: [0, 30, 0, 10],
},
{ text: order.paymentInstructions, style: "body" },
],
// Styles:
styles: {
invoiceTitle: { fontSize: 28, bold: true, color: "#2D6A4F" },
headerLogo: { fontSize: 18, bold: true, color: "#2D6A4F" },
headerRight: { fontSize: 11, color: "#666" },
tableHeader: { bold: true, fillColor: "#2D6A4F", color: "white", margin: [4, 6, 4, 6] },
label: { fontSize: 10, color: "#666", bold: true },
customerName: { fontSize: 13, bold: true },
meta: { fontSize: 10, color: "#555" },
sectionHeader: { fontSize: 14, bold: true, color: "#333" },
body: { fontSize: 10, color: "#555", lineHeight: 1.4 },
footer: { fontSize: 9, color: "#aaa" },
},
}
// Browser: download
pdfMake.createPdf(docDefinition).download("invoice.pdf")
// Browser: open in new tab
pdfMake.createPdf(docDefinition).open()
// Node.js: get buffer
return new Promise<Buffer>((resolve) => {
pdfMake.createPdf(docDefinition).getBuffer((buffer) => resolve(buffer))
})
}
pdfmake strengths:
- The document definition object is JSON-serializable — templates can be stored in a database
- Automatic page breaks and text reflowing — no manual coordinate calculations
- Column layouts, nested tables, and complex grid structures
- Non-developers can define document structure without writing coordinate math
- Strong multi-language and RTL support
Feature Comparison
| Feature | pdf-lib | jsPDF | pdfmake |
|---|---|---|---|
| TypeScript | ✅ Native | ✅ | ✅ |
| Browser support | ✅ | ✅ | ✅ |
| Node.js support | ✅ | ✅ | ✅ |
| Modify existing PDFs | ✅ | ⚠️ Limited | ❌ |
| Merge PDFs | ✅ | ❌ | ❌ |
| HTML to PDF | ❌ | ✅ (html2canvas) | ❌ |
| Document model | Low-level | Canvas-like | High-level declarative |
| Auto page breaks | ❌ (manual) | ❌ | ✅ |
| Custom fonts | ✅ TTF/OTF | ✅ | ✅ |
| Tables | ✅ (manual) | ⚠️ Plugin | ✅ Built-in |
| Images | ✅ | ✅ | ✅ |
| Watermarks | ✅ | ✅ | ✅ |
| Native deps | ❌ None | ❌ None | ❌ None |
| Bundle size | ~100KB | ~350KB | ~200KB |
Performance Considerations for High-Volume PDF Generation
PDF generation performance varies significantly across these libraries under load, and understanding the bottlenecks matters for server-side generation at scale. pdf-lib is a pure JavaScript implementation with no native bindings — a complex 10-page report with embedded images typically takes 100-300ms to generate on modern server hardware. For high-throughput scenarios (generating thousands of invoices during billing cycles), this translates to meaningful CPU time and requires horizontal scaling or a dedicated generation queue. jsPDF's rendering pipeline is similar in performance to pdf-lib for programmatic generation, but the html2canvas capture path is substantially slower — capturing a complex dashboard page can take 2-5 seconds because html2canvas must execute JavaScript, resolve CSS, and render the layout. pdfmake's document definition model incurs additional processing time to resolve text reflow, calculate page breaks, and lay out multi-column content, typically 200-500ms for a complex invoice. For high-volume production systems, a background job queue (BullMQ, Inngest) processing PDF generation tasks asynchronously is strongly recommended regardless of which library you use.
Puppeteer as an Alternative for HTML-Based PDFs
A fourth option deserves mention: using Puppeteer (or Playwright) to launch a headless browser and call page.pdf() produces PDFs that are pixel-perfect reproductions of HTML/CSS layouts — useful when your PDF must match a web page exactly, including web fonts, CSS grid, and Tailwind styles. Unlike jsPDF + html2canvas (which produces a rasterized image of the page), Puppeteer's PDF output is vector-based, selectable text, and fully printable. The trade-off is browser startup overhead: Puppeteer requires launching a Chromium process (~1-2 seconds cold start) and maintaining it as a service for warm subsequent requests. For development environments or low-volume generation, Puppeteer is often the simplest path to good-looking PDFs. Production Puppeteer PDF generation typically runs as a separate microservice that keeps a browser pool warm, accepting rendering jobs via a queue. The @sparticuz/chromium package provides a serverless-compatible Chromium binary for Lambda and similar environments.
Security Considerations: PDF File Handling
PDFs received from users or third parties must be treated as potentially malicious input. pdf-lib's PDFDocument.load() can trigger JavaScript execution if the loaded PDF contains JavaScript actions — this is a PDF specification feature used by malicious actors to exploit PDF readers. Loading user-supplied PDFs in a server-side context is generally safe since Node.js doesn't execute PDF JavaScript, but generating PDFs that embed external resources (links, embedded files) from user input requires sanitizing those inputs to prevent PDF injection attacks. When generating PDFs from user-supplied data (names, descriptions, order items), the text content is typically safe since it's treated as string data by the rendering engine rather than executable content. However, user-supplied URLs embedded in PDFs as links should be validated against an allowlist to prevent phishing via PDF links. Font subsetting (embedding only the glyphs used) reduces both file size and the surface area for font-based exploits in embedded font programs.
Font Embedding and Unicode Support
Production PDF generation for global applications requires careful font management. The three libraries handle internationalization differently. pdf-lib's StandardFonts (Helvetica, Times-Roman, Courier) support only Latin characters — for Chinese, Arabic, Japanese, Korean, or Cyrillic text, you must embed a custom TTF font using pdfDoc.registerFontkit(fontkit) and pdfDoc.embedFont(ttfBytes). This adds the TTF file size to every generated PDF (a CJK font can be 10-20MB), making font subsetting (embedding only used glyphs via the fontkit library) essential for maintaining reasonable file sizes. pdfmake supports custom fonts and handles CJK text layout, but similarly requires bundling the full font file or using subsetting. jsPDF + html2canvas sidesteps the font problem by rendering web fonts as part of the page screenshot, but this produces rasterized text that's not searchable or accessible. For document accessibility requirements (WCAG, PDF/UA compliance), text must be real selectable text — not an image — which rules out the html2canvas approach and requires proper font embedding.
Pricing and Self-Hosting Architecture
All three libraries are MIT or Apache licensed with no per-use fees, which is a significant advantage over commercial PDF generation services that charge per document. At scale (millions of PDF generations monthly), the savings over commercial APIs like Docmosis or APITemplate.io can be substantial. Self-hosting PDF generation comes with infrastructure responsibility: the generation process consumes CPU and memory proportional to document complexity, and burst capacity planning for billing cycle PDF generation (when all invoices generate simultaneously) requires either auto-scaling or queue-based rate limiting. A common production architecture is a dedicated Node.js service (not embedded in your main application server) with a BullMQ queue receiving generation jobs, a Redis-backed rate limiter, and the generated PDFs written to S3 with presigned URLs returned to the requesting service. This separation prevents PDF generation spikes from affecting the main application's response times.
When to Use Each
Choose pdf-lib if:
- You need to modify, merge, or extract pages from existing PDFs
- Building a PDF editor or form-fill application
- You want precise pixel-level control over every element
- No external binary dependencies is a hard requirement
Choose jsPDF if:
- You need HTML → PDF conversion (use with html2canvas)
- Capturing rendered React/HTML components as downloadable PDFs
- Simple single-page documents where canvas-like API is sufficient
- Already using jsPDF in an existing codebase
Choose pdfmake if:
- Generating invoice-style, report-style, or multi-page structured documents
- The document structure comes from data (rows in a table, items in an order)
- Non-developers need to define or modify document templates
- You need automatic text reflow, multi-column layouts, or complex table structures
Methodology
Download data from npm registry (weekly average, February 2026). Bundle sizes from bundlephobia. Feature comparison based on pdf-lib v1.17.x, jsPDF v2.x, and pdfmake v0.2.x documentation.
Compare document processing packages on PkgPulse →
See also: cac vs meow vs arg 2026 and Ink vs @clack/prompts vs Enquirer, acorn vs @babel/parser vs espree.