Scaling Server-Side PDF Exports: Why We Migrated from PDFKit to jsPDF + AutoTable

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.

:warning: 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

:magnifying_glass_tilted_left: 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-table helper 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:

  • :white_check_mark: Modifying existing PDFs (filling forms, adding watermarks, merging documents)
  • :white_check_mark: Fine-grained control over every PDF element position
  • :white_check_mark: Creating PDF forms with interactive fields (checkboxes, dropdowns, text inputs)
  • :white_check_mark: Embedding pages from other PDFs
  • :white_check_mark: Working with encrypted PDFs (with password)
  • :white_check_mark: Cross-platform support (works identically in Node.js, browser, Deno, React Native)

When pdf-lib is NOT the right choice:

  • :cross_mark: Generating reports with tables (our use case)
  • :cross_mark: Exporting large datasets with pagination
  • :cross_mark: When you need automatic table layout
  • :cross_mark: When developer productivity matters for report generation
  • :cross_mark: When you want built-in features like alternating row colors, column spanning

:globe_with_meridians: 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.

:white_check_mark: 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:

:bullseye: 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

:high_voltage: 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

:hammer_and_wrench: 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

:bar_chart: 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');
}

:artist_palette: 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
});

:rocket: 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

:hammer_and_wrench: 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)

:chart_increasing: 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

:floppy_disk: 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');
}

:wrench: 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)
    )
  ]);
}

:books: 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 :white_check_mark: Yes Limited No
Form Fields No :white_check_mark: Yes 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

:light_bulb: Key Takeaways

PDF generation at scale is a backend architecture decision, not just a formatting choice.

Our Journey:

  1. Started with PDFKit → Manual table rendering became unmaintainable
  2. Tried pdf-lib → Great for PDF manipulation, terrible for table generation
  3. Evaluated HTML/CSS (Puppeteer) → Too resource-intensive for server-side at scale
  4. Settled on jsPDF + AutoTable → Perfect fit for structured data exports

Choose jsPDF + AutoTable when:

  • :white_check_mark: You need to export large, structured datasets (tables, reports, invoices)
  • :white_check_mark: Server-side performance and resource efficiency are critical
  • :white_check_mark: You want native table support with automatic pagination
  • :white_check_mark: Developer productivity and maintainability matter
  • :white_check_mark: You’re building for Node.js backend services
  • :white_check_mark: You need automatic header repetition across pages

Choose pdf-lib when:

  • :white_check_mark: You need to modify existing PDFs (fill forms, merge, split, add watermarks)
  • :white_check_mark: You’re creating interactive PDF forms with fields
  • :white_check_mark: You need fine-grained control over every element
  • :white_check_mark: You’re embedding pages from multiple PDFs
  • :white_check_mark: 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:

  • :white_check_mark: Tried PDFKit → Manual table rendering was unmaintainable
  • :white_check_mark: Evaluated pdf-lib → Great library, but wrong tool for table-based reports
  • :white_check_mark: Tested HTML/CSS → Resource overhead too high for production scale
  • :white_check_mark: Chose jsPDF + AutoTable → Perfect fit for our needs
  • :white_check_mark: 3-5x faster generation times vs PDFKit
  • :white_check_mark: 60% reduction in memory usage
  • :white_check_mark: 70% less custom code to maintain (compared to both PDFKit and pdf-lib)
  • :white_check_mark: Zero pagination issues
  • :white_check_mark: Seamless integration with job queues
  • :white_check_mark: 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.

:open_book: Resources

jsPDF + AutoTable:

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!

1 Like