If your application exports large lists of records to PDF — for reporting, audits, compliance, invoicing, or external sharing — you’ve likely run into scaling issues as data volumes grow. What begins as a simple feature often becomes a performance hotspot when exporting thousands of rows. We faced this challenge while building our server-side PDF export feature, and migrating from PDFKit to jsPDF with the AutoTable plugin brought significant improvements in both performance and developer experience.
Problems with PDFKit at Scale
Initially, PDFKit worked fine for small datasets, but as exports grew, its limitations became evident:
- Slow generation for large datasets - User wait times increased dramatically with more rows
- No native table support - We had to write custom rendering logic from scratch
- Fragile pagination - Page breaks often occurred in the middle of table rows
- Inconsistent column widths - Maintaining alignment across pages was challenging
- Memory spikes - Large exports consumed excessive memory on background workers
- Complex custom code - Every new report format required extensive manual positioning logic
Additional operational challenges:
- Broken row grouping for multi-page tables
- Repeated headers required manual implementation
- High regression risk when adding new report formats
- Increased operational complexity with concurrent exports
- Difficult to maintain and debug layout code
We Also Tried pdf-lib (Same Manual Table Issues)
Before settling on jsPDF + AutoTable, we also evaluated pdf-lib — a popular library known for its ability to create and modify existing PDFs. On paper, it seemed promising: excellent for PDF manipulation, good TypeScript support, and works across all JavaScript environments.
However, we quickly discovered it had the same table generation problems as PDFKit:
Critical Limitation: No Native Table Support
Just like PDFKit, pdf-lib has no built-in table structures.
What We Encountered (same as PDFKit):
- Manual cell-by-cell drawing - Every table required calculating X/Y coordinates for each cell
- No automatic pagination - We had to manually track when content exceeded page boundaries
- Complex row/column logic - Implementing column widths, alignment, and spanning required extensive custom code
- Header repetition nightmare - Repeating table headers across pages meant duplicating all positioning logic
- Third-party wrapper dependency - Found
pdf-lib-draw-tablehelper library, but it added another dependency and still lacked the robustness we needed
Both PDFKit and pdf-lib require similar amounts of manual code for table generation (~60-70 lines vs jsPDF’s ~15 lines).
Example of PDFKit table complexity:
import PDFDocument from 'pdfkit';
// With PDFKit - Manual positioning nightmare
const doc = new PDFDocument();
const margin = 50;
let yPosition = 100;
const rowHeight = 20;
const columnWidths = [100, 150, 200, 100];
const pageHeight = doc.page.height;
// Helper to draw header
function drawTableHeader(doc, y) {
let x = margin;
// Draw header background
doc.rect(margin, y, 550, rowHeight).fill('#144EA2');
// Draw header text
doc.fillColor('#FFFFFF').fontSize(10);
['Date', 'Name', 'Description', 'Amount'].forEach((header, i) => {
doc.text(header, x + 5, y + 5, { width: columnWidths[i] });
x += columnWidths[i];
});
doc.fillColor('#000000');
return y + rowHeight;
}
// Draw initial header
yPosition = drawTableHeader(doc, yPosition);
// Manually draw each row
data.forEach((row, index) => {
// Check if we need a new page - MANUAL LOGIC!
if (yPosition > pageHeight - 100) {
doc.addPage();
yPosition = 50;
// REDRAW HEADERS MANUALLY - must duplicate all header code!
yPosition = drawTableHeader(doc, yPosition);
}
let xPosition = margin;
const rowData = [row.date, row.name, row.description, row.amount];
// Draw row background (alternating)
if (index % 2 === 0) {
doc.rect(margin, yPosition, 550, rowHeight).fill('#FAFAFA');
}
// Draw cell borders and text
doc.fillColor('#333333').fontSize(9);
rowData.forEach((cell, i) => {
// Draw cell border
doc.rect(xPosition, yPosition, columnWidths[i], rowHeight).stroke();
// Draw cell text
doc.text(String(cell), xPosition + 5, yPosition + 5, {
width: columnWidths[i] - 10,
height: rowHeight,
ellipsis: true
});
xPosition += columnWidths[i];
});
yPosition += rowHeight;
});
const pdfBytes = await new Promise((resolve) => {
const chunks = [];
doc.on('data', chunk => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.end();
});
Compare to jsPDF + AutoTable (exact same output):
import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
// With jsPDF + AutoTable - Clean and declarative
const doc = new jsPDF();
autoTable(doc, {
head: [['Date', 'Name', 'Description', 'Amount']],
body: data.map(row => [row.date, row.name, row.description, row.amount]),
headStyles: {
fillColor: [20, 78, 162],
textColor: [255, 255, 255]
},
alternateRowStyles: {
fillColor: [250, 250, 250]
},
showHead: 'everyPage' // Headers automatically repeated on every page!
});
const pdfBytes = doc.output('arraybuffer');
The difference is staggering:
- PDFKit: ~60 lines of manual positioning code with helper function
- jsPDF + AutoTable: ~15 lines of declarative configuration
- PDFKit: Manual page break detection and header duplication
- jsPDF + AutoTable: Automatic pagination with
showHead: 'everyPage'
When pdf-lib IS the right choice:
Modifying existing PDFs (filling forms, adding watermarks, merging documents)
Fine-grained control over every PDF element position
Creating PDF forms with interactive fields (checkboxes, dropdowns, text inputs)
Embedding pages from other PDFs
Working with encrypted PDFs (with password)
Cross-platform support (works identically in Node.js, browser, Deno, React Native)
When pdf-lib is NOT the right choice:
Generating reports with tables (our use case)
Exporting large datasets with pagination
When you need automatic table layout
When developer productivity matters for report generation
When you want built-in features like alternating row colors, column spanning
Why We Didn’t Choose HTML/CSS-Based Solutions
We also explored generating PDFs via HTML/CSS rendering with tools like Puppeteer or wkhtmltopdf. On paper, this looked attractive — reuse frontend styling, simplify layout adjustments, and maintain consistent visuals. However, it introduced several critical problems:
- High resource consumption - Headless browser rendering (Puppeteer/Chromium) consumed excessive CPU & memory
- Slow generation times - Browser startup and rendering overhead added significant latency
- Fragile pagination - CSS page-break properties are unreliable for complex tables
- Unpredictable layouts - Debugging layout issues across different environments was difficult
- Operational overhead - Running and maintaining browser renderers in production added complexity
- Scaling challenges - Difficult to handle concurrent exports efficiently
While HTML/CSS-based exports work well for small, visually rich documents, they were impractical for large, structured datasets at scale.
Why jsPDF + AutoTable Was the Right Choice
jsPDF is a popular PDF generation library that works in both browser and Node.js environments. Combined with the AutoTable plugin, it provides powerful table generation capabilities that solved all our core problems.
Key advantages:
Built-in Table Support
The AutoTable plugin provides native table structures with:
- Automatic multi-page table handling
- Built-in header repetition on every page
- Clean, predictable page breaks
- Column width controls and alignment
- Row grouping and styling
- Alternating row colors out of the box
Performance at Scale
- Fast PDF generation even for thousands of rows
- Lower memory footprint compared to PDFKit
- Fits naturally into asynchronous processing pipelines
- Predictable execution time scaling with dataset size
- Works efficiently in queue-based background job systems
Developer Experience
- Significantly less custom code required
- Clear separation between data and layout
- Easy to add new report formats
- Deterministic, testable output
- Excellent documentation and active community
- TypeScript support available
Code Example: Basic Table Generation
Here’s how simple it is to create a professional table with jsPDF + AutoTable:
import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
async function generateInvoiceReport(data) {
const doc = new jsPDF({
orientation: 'landscape',
unit: 'pt',
format: 'a4',
compress: true
});
// Define columns
const columns = [
{ header: 'Invoice ID', dataKey: 'id' },
{ header: 'Customer', dataKey: 'customer' },
{ header: 'Date', dataKey: 'date' },
{ header: 'Amount', dataKey: 'amount' }
];
// Generate table
autoTable(doc, {
head: [columns.map(col => col.header)],
body: data.map(row => columns.map(col => row[col.dataKey])),
theme: 'grid',
headStyles: {
fillColor: [20, 78, 162],
textColor: [255, 255, 255],
fontSize: 10,
fontStyle: 'bold',
halign: 'left'
},
bodyStyles: {
fontSize: 9,
textColor: [51, 51, 51]
},
alternateRowStyles: {
fillColor: [250, 250, 250]
},
margin: { top: 50, right: 15, bottom: 50, left: 15 },
showHead: 'everyPage' // Repeat headers on every page
});
return doc.output('arraybuffer');
}
Advanced Features We Use
Custom Cell Rendering with Hooks
AutoTable provides powerful hooks for customization:
autoTable(doc, {
// ... configuration
didParseCell: (data) => {
// Customize cell content before rendering
if (data.column.index === 0 && data.row.index === 0) {
data.cell.styles.fontStyle = 'bold';
data.cell.styles.fillColor = [227, 242, 253];
}
},
willDrawCell: (data) => {
// Execute before cell is drawn
if (data.section === 'head') {
doc.setFillColor(20, 78, 162);
}
},
didDrawCell: (data) => {
// Add custom content after cell is drawn
if (data.section === 'body' && data.column.index === 5) {
// Add clickable receipt links
if (data.cell.raw === 'View Receipt') {
doc.link(
data.cell.x,
data.cell.y,
data.cell.width,
data.cell.height,
{ url: receiptUrls[data.row.index] }
);
}
}
},
didDrawPage: (data) => {
// Add headers, footers, page numbers
doc.setFontSize(10);
doc.text(
`Page ${data.pageNumber}`,
doc.internal.pageSize.width - 50,
doc.internal.pageSize.height - 30
);
}
});
Multi-Page Table with Custom Headers
autoTable(doc, {
startY: 150, // Start after custom header content
head: [['Date', 'MNO', 'Transaction', 'Amount']],
body: transactionData,
margin: { left: 15, right: 15 },
// Repeat custom header on each page
didDrawPage: (data) => {
// Add logo and company info
doc.addImage(logoBase64, 'PNG', 25, 50, 80, 60);
// Add page title
doc.setFontSize(16);
doc.setFont('helvetica', 'bold');
doc.text('Transaction Report', 15, 140);
// Add page numbers
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.text(
`Page ${data.pageNumber} of ${totalPages}`,
pageWidth - 80,
pageHeight - 40
);
}
});
Column Width Control and Alignment
const columnStyles = {
0: { cellWidth: 80, halign: 'left' }, // Date column
1: { cellWidth: 120, halign: 'left' }, // Name column
2: { cellWidth: 150, halign: 'left' }, // Description
3: { cellWidth: 100, halign: 'right' } // Amount (right-aligned)
};
autoTable(doc, {
// ... other options
columnStyles: columnStyles
});
Performance & Resource Improvements
After migrating to jsPDF + AutoTable:
- Generation speed: 3-5x faster for large exports (5000+ rows)
- Memory usage: 60% reduction in peak memory consumption
- Predictable scaling: Linear performance increase with row count
- Background job compatibility: Seamlessly integrates with job queues (Bull, BullMQ)
- Concurrent exports: Can handle multiple simultaneous exports without resource contention
- Timeout reduction: Fewer timeout failures on large reports
Benchmark example (10,000 row export):
- PDFKit: ~45 seconds, 800MB peak memory
- jsPDF + AutoTable: ~12 seconds, 320MB peak memory
Code & Maintainability Wins
Before (PDFKit - manual positioning):
// Complex manual table rendering
let y = 100;
const rowHeight = 20;
const columnWidths = [100, 150, 200, 100];
data.forEach((row, index) => {
if (y > pageHeight - 50) {
doc.addPage();
y = 50;
// Manually redraw headers
drawHeaders(doc, columnWidths);
y += rowHeight;
}
let x = margin;
doc.text(row.date, x, y);
x += columnWidths[0];
doc.text(row.name, x, y);
x += columnWidths[1];
// ... repeat for each column
y += rowHeight;
});
After (jsPDF + AutoTable - declarative):
// Clean, declarative approach
autoTable(doc, {
head: [['Date', 'Name', 'Description', 'Amount']],
body: data.map(row => [row.date, row.name, row.desc, row.amount]),
showHead: 'everyPage' // Headers automatically repeated
});
Maintainability improvements:
- 70% reduction in custom layout code
- Easier to evolve report formats without touching core rendering
- Deterministic output enables automated testing
- New engineers can contribute to reports in days vs. weeks
- Clear separation of concerns (data vs. presentation)
Operational & Platform Improvements
Better Observability
// Easy to track performance metrics
const startTime = Date.now();
const pdfBuffer = await generateReport(data);
const duration = Date.now() - startTime;
const sizeKB = Buffer.byteLength(pdfBuffer) / 1024;
metrics.recordPdfGeneration({
duration,
size: sizeKB,
rowCount: data.length,
success: true
});
Async Job Support
// Works seamlessly with job queues
async function processExportJob(job) {
const { exportId, userId, filters } = job.data;
// Update progress
await job.updateProgress(10);
const data = await fetchData(filters);
await job.updateProgress(50);
const pdfBuffer = await generatePDF(data);
await job.updateProgress(90);
// Upload to storage
const url = await uploadToS3(pdfBuffer, exportId);
await job.updateProgress(100);
// Notify user
await notifyUser(userId, { exportId, url });
return { exportId, url };
}
Operational wins:
- Better visibility into export performance and failure rates
- Easy progress tracking for long-running exports
- Isolated failures don’t affect other jobs
- Can implement retry logic with confidence
- Easier capacity planning based on metrics
Handling Very Large Datasets
For exports with 50,000+ rows, we implemented additional optimizations:
Chunked Processing
async function generateLargeExport(dataStream, batchSize = 5000) {
const doc = new jsPDF({ orientation: 'landscape' });
let batch = [];
let isFirstBatch = true;
for await (const record of dataStream) {
batch.push(record);
if (batch.length >= batchSize) {
autoTable(doc, {
startY: isFirstBatch ? 100 : undefined,
body: batch.map(formatRow),
showHead: isFirstBatch ? 'firstPage' : 'everyPage'
});
batch = [];
isFirstBatch = false;
}
}
// Process remaining records
if (batch.length > 0) {
autoTable(doc, {
body: batch.map(formatRow),
showHead: false
});
}
return doc.output('arraybuffer');
}
Progress Reporting
async function generateWithProgress(data, progressCallback) {
const doc = new jsPDF();
const totalRows = data.length;
const batchSize = 1000;
for (let i = 0; i < totalRows; i += batchSize) {
const batch = data.slice(i, i + batchSize);
autoTable(doc, {
body: batch,
showHead: i === 0 ? 'firstPage' : false
});
const progress = Math.min(((i + batchSize) / totalRows) * 100, 100);
await progressCallback(progress);
}
return doc.output('arraybuffer');
}
Production Considerations
Worker Configuration
// Optimal worker settings for PDF generation
const workerConfig = {
concurrency: 4, // Limit concurrent PDF generations
limiter: {
max: 10, // Max 10 jobs per interval
duration: 60000 // Per minute
},
settings: {
maxStalledCount: 3,
stalledInterval: 30000
}
};
const pdfQueue = new Queue('pdf-exports', {
...workerConfig,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000
},
removeOnComplete: {
age: 86400, // Keep completed jobs for 24h
count: 1000
}
}
});
Resource Safeguards
async function generateWithSafeguards(data, options = {}) {
const maxRows = options.maxRows || 100000;
const timeoutMs = options.timeout || 300000; // 5 minutes
if (data.length > maxRows) {
throw new Error(`Export exceeds maximum of ${maxRows} rows`);
}
return Promise.race([
generatePDF(data),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('PDF generation timeout')), timeoutMs)
)
]);
}
Feature Comparison Table
| Feature | PDFKit | pdf-lib | jsPDF + AutoTable | HTML/CSS (Puppeteer) |
|---|---|---|---|---|
| Table Support | Manual | Manual | Native (AutoTable) | CSS-based |
| Multi-page Tables | Manual | Manual | Automatic | CSS page-break |
| Header Repetition | Manual | Manual | Automatic (showHead) |
CSS page-break |
| Generation Speed | Moderate | Fast | Very Fast | Slow |
| Memory Usage | High | Moderate | Low | Very High |
| Column Control | Manual positioning | Manual positioning | Built-in config | CSS Grid/Flexbox |
| Custom Styling | Full control | Full control | Hooks + Styles | CSS |
| Learning Curve | Steep | Moderate | Moderate | Easy (if you know CSS) |
| Node.js Performance | Good | Good | Excellent | Poor (browser overhead) |
| Browser Support | Limited | Full | Full | N/A (server-side) |
| PDF Modification | No | Limited | No | |
| Form Fields | No | No | No | |
| Best For | Custom layouts | PDF manipulation | Table reports | Visual documents |
| Lines of Code (typical table) | ~80 | ~70 | ~15 | ~40 |
| Maintenance | High | High | Low | Moderate |
| Concurrent Exports | Challenging | Moderate | Easy | Very Challenging |
Key Takeaways
PDF generation at scale is a backend architecture decision, not just a formatting choice.
Our Journey:
- Started with PDFKit → Manual table rendering became unmaintainable
- Tried pdf-lib → Great for PDF manipulation, terrible for table generation
- Evaluated HTML/CSS (Puppeteer) → Too resource-intensive for server-side at scale
- Settled on jsPDF + AutoTable → Perfect fit for structured data exports
Choose jsPDF + AutoTable when:
You need to export large, structured datasets (tables, reports, invoices)
Server-side performance and resource efficiency are critical
You want native table support with automatic pagination
Developer productivity and maintainability matter
You’re building for Node.js backend services
You need automatic header repetition across pages
Choose pdf-lib when:
You need to modify existing PDFs (fill forms, merge, split, add watermarks)
You’re creating interactive PDF forms with fields
You need fine-grained control over every element
You’re embedding pages from multiple PDFs
Cross-platform consistency is critical (Node/Browser/Deno/React Native)
Stick with PDFKit when:
- You need very fine-grained control over every element
- You’re creating highly custom, artistic layouts
- Your PDFs are small and simple
- You have existing PDFKit expertise and infrastructure
- PDF modification is not required
Use HTML/CSS (Puppeteer) when:
- You need to convert complex web pages to PDF
- Visual fidelity to existing web UI is critical
- Export volumes are low (< 100/day)
- You can afford the resource overhead
- Visual design complexity outweighs performance concerns
Our Migration Results:
Tried PDFKit → Manual table rendering was unmaintainable
Evaluated pdf-lib → Great library, but wrong tool for table-based reports
Tested HTML/CSS → Resource overhead too high for production scale
Chose jsPDF + AutoTable → Perfect fit for our needs
3-5x faster generation times vs PDFKit
60% reduction in memory usage
70% less custom code to maintain (compared to both PDFKit and pdf-lib)
Zero pagination issues
Seamless integration with job queues
Better developer experience and faster iterations
Migrating from PDFKit to jsPDF + AutoTable (after evaluating pdf-lib and HTML/CSS solutions) transformed our PDF export system from a constant source of performance issues and maintenance burden into a reliable, efficient, and developer-friendly feature that scales effortlessly with our growing data volumes.
Resources
jsPDF + AutoTable:
- jsPDF GitHub Repository
- jsPDF AutoTable Plugin
- jsPDF AutoTable Documentation
- AutoTable Examples and Demos
pdf-lib (for PDF manipulation):
Other Resources:
Have you dealt with PDF generation at scale? What challenges did you face? Which library did you choose and why? Share your experiences in the comments below!