← Back to Blog
React2026-05-14Β·98 min read

PDF Generation on the Server: Puppeteer vs @react-pdf/renderer (A Production Comparison)

By Iurii Rogulia

Architecting Server-Side Document Rendering: Browser Engines vs Declarative Layouts

Current Situation Analysis

Modern cloud deployments have fundamentally changed how backend services handle document generation. The traditional assumption that a server environment provides unlimited memory, persistent disk space, and unrestricted binary execution no longer holds. Platform-as-a-Service (PaaS) providers and serverless runtimes enforce strict boundaries: Vercel's default function limit caps at 50 MB, AWS Lambda enforces a 250 MB deployment package limit, and cold-start latency directly impacts API response times.

Despite these constraints, many engineering teams treat PDF generation as a trivial HTML-to-binary conversion task. This misunderstanding stems from legacy mental models where a dedicated VPS could comfortably host a full Chromium installation. In reality, shipping a ~100 MB browser binary into a serverless function triggers deployment failures, memory throttling, and unpredictable cold starts. Teams often discover these failures only after hitting production traffic, resulting in cascading timeouts and degraded user experience.

The core tension lies in rendering fidelity versus runtime efficiency. Browser-based engines guarantee pixel-perfect reproduction of web layouts but carry heavy operational overhead. Declarative layout engines eliminate the browser dependency entirely, trading CSS completeness for deterministic, lightweight execution. Neither approach is universally superior; the correct architecture depends entirely on deployment topology, document complexity, and internationalization requirements.

WOW Moment: Key Findings

The performance and operational characteristics of these two rendering paradigms diverge sharply across measurable dimensions. Understanding these metrics allows teams to align their rendering strategy with infrastructure constraints before deployment.

Approach Bundle Size Generation Time CSS Support Serverless Readiness Font Handling
Headless Browser (Puppeteer) ~100 MB 2–5 seconds Full HTML/CSS (Grid, Flex, Pseudo) Requires binary optimization Native browser font stack
Declarative Renderer (@react-pdf/renderer) ~2 MB <500 ms Subset (Flexbox only, no Grid) Native compatibility Explicit registration required

This comparison reveals a critical architectural insight: browser engines solve layout fidelity at the cost of runtime predictability, while declarative engines solve deployment efficiency at the cost of styling flexibility. The finding matters because it shifts the decision from "which library is faster" to "which rendering model aligns with our infrastructure boundaries and document requirements." Teams deploying to constrained environments should default to declarative rendering for structured documents, reserving browser engines for complex, web-matching layouts hosted on dedicated infrastructure.

Core Solution

Building a production-ready PDF generation pipeline requires separating rendering logic from business operations, managing runtime resources explicitly, and handling internationalization deterministically. Below are two architectural patterns optimized for their respective deployment targets.

Pattern 1: Declarative Document Pipeline (Serverless-Optimized)

This approach uses @react-pdf/renderer to construct documents using React primitives. The architecture prioritizes cold-start performance, memory efficiency, and explicit font management.

Architecture Decisions:

  • Explicit Font Registration: The renderer ships with a Latin-only fallback font. Non-Latin scripts (Cyrillic, CJK, Arabic) require pre-registered typefaces. Fonts are cached at module initialization to avoid repeated I/O operations.
  • Stream-Based Output: Instead of buffering the entire document in memory, the pipeline streams binary chunks directly to the HTTP response or storage service.
  • Layout Constraints: Flexbox is used for all structural alignment. CSS Grid, pseudo-selectors, and media queries are avoided entirely, as the layout engine does not parse them.
// src/document-engine/react-pdf-builder.ts
import { pdf, Document, Page, Text, View, StyleSheet, Font } from "@react-pdf/renderer";
import { readFileSync } from "fs";
import { join } from "path";

interface InvoiceLineItem {
  sku: string;
  description: string;
  qty: number;
  unitCost: number;
  taxRate: number;
}

interface DocumentPayload {
  referenceId: string;
  issueDate: string;
  dueDate: string;
  vendor: { name: string; regId: string; address: string };
  client: { name: string; regId?: string; address: string };
  items: InvoiceLineItem[];
  currency: string;
  locale: "en" | "ru" | "uk";
}

const FONT_PATHS = {
  regular: join(process.cwd(), "assets", "fonts", "Inter-Regular.ttf"),
  bold: join(process.cwd(), "assets", "fonts", "Inter-Bold.ttf"),
};

let fontRegistryInitialized = false;

function initializeFontRegistry(): void {
  if (fontRegistryInitialized) return;

  Font.register({
    family: "Inter",
    fonts: [
      { src: FONT_PATHS.regular, fontWeight: "normal" },
      { src: FONT_PATHS.bold, fontWeight: "bold" },
    ],
  });

  fontRegistryInitialized = true;
}

const layoutStyles = StyleSheet.create({
  container: {
    fontFamily: "Inter",
    fontSize: 11,
    padding: 36,
    color: "#111827",
  },
  headerRow: {
    flexDirection: "row",
    justifyContent: "space-between",
    marginBottom: 24,
    borderBottomWidth: 1,
    borderBottomColor: "#e5e7eb",
    paddingBottom: 12,
  },
  gridRow: {
    flexDirection: "row",
    paddingVertical: 8,
    borderBottomWidth: 0.5,
    borderBottomColor: "#f3f4f6",
  },
  col: { flex: 1 },
  colFixed: { width: 80 },
  label: { fontSize: 9, color: "#6b7280", marginBottom: 2 },
  amount: { textAlign: "right" as const },
});

function InvoiceDocument({ payload }: { payload: DocumentPayload }) {
  const subtotal = payload.items.reduce((acc, item) => acc + item.qty * item.unitCost, 0);
  const taxTotal = payload.items.reduce((acc, item) => acc + (item.qty * item.unitCost * item.taxRate), 0);
  const grandTotal = subtotal + taxTotal;

  return (
    <Document>
      <Page size="A4" style={layoutStyles.container}>
        <View style={layoutStyles.headerRow}>
          <View>
            <Text style={{ fontSize: 18, fontWeight: "bold" }}>{payload.vendor.name}</Text>
            <Text>{payload.vendor.address}</Text>
            <Text style={layoutStyles.label}>Reg: {payload.vendor.regId}</Text>
          </View>
          <View style={{ alignItems: "flex-end" }}>
            <Text style={{ fontSize: 14, fontWeight: "bold" }}>INVOICE</Text>
            <Text>{payload.referenceId}</Text>
            <Text>Issued: {payload.issueDate}</Text>
            <Text>Due: {payload.dueDate}</Text>
          </View>
        </View>

        <View style={{ marginBottom: 20 }}>
          <Text style={layoutStyles.label}>BILL TO</Text>
          <Text style={{ fontWeight: "bold" }}>{payload.client.name}</Text>
          <Text>{payload.client.address}</Text>
          {payload.client.regId && <Text style={layoutStyles.label}>Reg: {payload.client.regId}</Text>}
        </View>

        <View style={layoutStyles.gridRow}>
          <View style={layoutStyles.col}><Text style={{ fontWeight: "bold" }}>Description</Text></View>
          <View style={layoutStyles.colFixed}><Text style={{ fontWeight: "bold" }}>Qty</Text></View>
          <View style={layoutStyles.colFixed}><Text style={{ fontWeight: "bold" }}>Price</Text></View>
          <View style={layoutStyles.colFixed}><Text style={{ fontWeight: "bold" }}>Total</Text></View>
        </View>

        {payload.items.map((item, idx) => (
          <View key={idx} style={layoutStyles.gridRow}>
            <View style={layoutStyles.col}><Text>{item.description}</Text></View>
            <View style={layoutStyles.colFixed}><Text>{item.qty}</Text></View>
            <View style={layoutStyles.colFixed}><Text>{item.unitCost.toFixed(2)}</Text></View>
            <View style={layoutStyles.colFixed}><Text style={layoutStyles.amount}>{(item.qty * item.unitCost).toFixed(2)}</Text></View>
          </View>
        ))}

        <View style={{ marginTop: 20, alignItems: "flex-end" }}>
          <Text>Subtotal: {subtotal.toFixed(2)} {payload.currency}</Text>
          <Text>Tax: {taxTotal.toFixed(2)} {payload.currency}</Text>
          <Text style={{ fontWeight: "bold", marginTop: 4 }}>Total: {grandTotal.toFixed(2)} {payload.currency}</Text>
        </View>
      </Page>
    </Document>
  );
}

export async function generateDeclarativePDF(payload: DocumentPayload): Promise<Buffer> {
  initializeFontRegistry();
  const blob = await pdf(<InvoiceDocument payload={payload} />).toBlob();
  const arrayBuffer = await blob.arrayBuffer();
  return Buffer.from(arrayBuffer);
}

Pattern 2: Headless Rendering Pipeline (VPS/Dedicated-Optimized)

This approach leverages Puppeteer to render HTML templates into PDFs. The architecture prioritizes layout fidelity, browser compatibility, and connection lifecycle management.

Architecture Decisions:

  • Connection Pooling: Chromium instances are expensive to spawn. A singleton browser manager maintains a persistent connection, recycling pages per request.
  • Lazy Binary Resolution: When deployed to constrained environments, the executable path is resolved dynamically using a serverless-optimized Chromium package.
  • Network Idle Strategy: networkidle0 ensures external assets (fonts, images, stylesheets) fully resolve before capture, preventing missing resources in the output.
// src/document-engine/browser-renderer.ts
import puppeteer, { Browser, Page, BrowserLaunchOptions } from "puppeteer";

interface RenderConfig {
  htmlTemplate: string;
  format: "A4" | "Letter" | "Legal";
  margins: { top: string; right: string; bottom: string; left: string };
  includeBackgrounds: boolean;
}

class BrowserManager {
  private instance: Browser | null = null;
  private launchOptions: BrowserLaunchOptions;

  constructor(options: Partial<BrowserLaunchOptions> = {}) {
    this.launchOptions = {
      headless: true,
      args: [
        "--no-sandbox",
        "--disable-setuid-sandbox",
        "--disable-dev-shm-usage",
        "--disable-gpu",
        "--single-process",
      ],
      ...options,
    };
  }

  async acquire(): Promise<Browser> {
    if (!this.instance || !this.instance.isConnected()) {
      this.instance = await puppeteer.launch(this.launchOptions);
    }
    return this.instance;
  }

  async release(): Promise<void> {
    if (this.instance?.isConnected()) {
      await this.instance.close();
      this.instance = null;
    }
  }
}

const renderer = new BrowserManager();

export async function renderHTMLToPDF(config: RenderConfig): Promise<Buffer> {
  const browser = await renderer.acquire();
  const page: Page = await browser.newPage();

  try {
    await page.setContent(config.htmlTemplate, {
      waitUntil: "networkidle0",
      timeout: 15000,
    });

    const pdfBuffer = await page.pdf({
      format: config.format,
      printBackground: config.includeBackgrounds,
      margin: config.margins,
      preferCSSPageSize: true,
    });

    return Buffer.from(pdfBuffer);
  } finally {
    await page.close();
  }
}

export async function teardownBrowserPool(): Promise<void> {
  await renderer.release();
}

Why These Choices Matter:

  • Declarative rendering eliminates the browser runtime entirely, reducing memory footprint by ~98% and guaranteeing sub-second generation times. Font registration is explicit, preventing silent rendering failures.
  • Browser rendering preserves CSS fidelity but requires strict lifecycle management. Connection pooling prevents cold-start overhead, while networkidle0 prevents asset truncation. The finally block ensures page cleanup without terminating the browser instance, balancing resource usage and performance.

Pitfall Guide

1. Cold Browser Initialization Overhead

Explanation: Spawning a new Chromium process per request adds 1–2 seconds of startup latency. In high-throughput environments, this dominates total response time. Fix: Maintain a persistent browser instance. Open and close pages per request, but keep the browser alive. Implement graceful shutdown hooks to terminate the instance during server teardown.

2. Silent Font Fallback Failures

Explanation: Declarative renderers ship with Latin-only fallback fonts. Non-Latin characters render as empty boxes or replacement glyphs without throwing errors. Fix: Register required typefaces at module initialization. Verify font coverage for target Unicode ranges. Set fontFamily at the root document level to enforce consistent fallback behavior.

3. Misconfigured Network Wait Strategies

Explanation: Using load or domcontentloaded instead of networkidle0 causes PDF capture before external fonts or images finish downloading. Fix: Always use networkidle0 or networkidle2 for HTML-to-PDF conversion. Set explicit timeouts (10–15s) to prevent indefinite hangs on slow third-party assets.

4. Serverless Memory Boundary Violations

Explanation: Headless Chrome consumes 400–600 MB per invocation. Exceeding platform memory limits triggers OOM kills and silent failures. Fix: Monitor function memory allocation. If using browser rendering on serverless, enable lazy binary loading, reduce viewport dimensions, and avoid heavy JavaScript execution during capture. Consider offloading to a dedicated VPS.

5. CSS Feature Assumptions in Declarative Renderers

Explanation: Declarative engines do not parse CSS Grid, :hover, @media, or complex selectors. Assuming web CSS compatibility results in broken layouts. Fix: Restrict styling to flexbox, explicit widths, and inline style objects. Test layouts in isolation before integrating with business logic. Use @react-pdf/renderer's @page and @font-face equivalents for document-level configuration.

6. Blocking the Event Loop with Synchronous Font Loading

Explanation: Reading font files synchronously during request handling blocks the Node.js event loop, degrading throughput for all concurrent requests. Fix: Load and register fonts during application startup. Cache font buffers in memory or use a dedicated font service. Never perform synchronous I/O inside request handlers.

7. Ignoring Page Break Logic in Multi-Page Documents

Explanation: Declarative renderers do not automatically paginate complex content. Long tables or unbounded text blocks overflow page boundaries silently. Fix: Use explicit page break components (<PageBreak /> or equivalent). Structure content into bounded sections. Test with maximum-length data to verify pagination behavior before production deployment.

Production Bundle

Action Checklist

  • Audit deployment constraints: Verify function size limits, memory allocation, and cold-start tolerance before selecting a rendering engine.
  • Register fonts at startup: Load and cache required typefaces during application initialization to prevent runtime I/O blocking.
  • Implement connection pooling: For browser-based rendering, maintain a persistent instance and recycle pages per request.
  • Enforce layout constraints: Restrict declarative documents to flexbox and explicit dimensions. Avoid web-specific CSS features.
  • Configure network wait strategies: Use networkidle0 with explicit timeouts for HTML-to-PDF conversion to ensure asset completeness.
  • Test pagination boundaries: Validate multi-page documents with maximum-length data to verify break behavior and content overflow.
  • Monitor memory consumption: Track invocation memory usage and set alerts for OOM thresholds, especially in serverless environments.

Decision Matrix

Scenario Recommended Approach Why Cost Impact
Structured invoices/receipts on Vercel/AWS Lambda Declarative Renderer Native serverless compatibility, <500ms generation, ~2MB bundle Minimal infrastructure cost, predictable scaling
Complex web-matching layouts on VPS/Dedicated Headless Browser Full CSS/HTML support, pixel-perfect reproduction, existing template reuse Higher compute cost, requires persistent memory allocation
Multi-language documents (Cyrillic/CJK/Arabic) Declarative Renderer (with font registration) Explicit font control prevents silent fallback failures Slight increase in deployment size (~1-3MB per font family)
Real-time dashboard exports Headless Browser Captures dynamic state, charts, and complex grid layouts accurately Higher latency, requires connection pooling to maintain throughput
High-volume batch generation Declarative Renderer Deterministic execution, lower memory footprint, easier horizontal scaling Reduced compute costs, faster queue processing

Configuration Template

// src/config/pdf-engine.factory.ts
import { renderHTMLToPDF, teardownBrowserPool } from "@/document-engine/browser-renderer";
import { generateDeclarativePDF } from "@/document-engine/react-pdf-builder";

export type RenderStrategy = "declarative" | "browser";

interface EngineConfig {
  strategy: RenderStrategy;
  browserOptions?: {
    executablePath?: string;
    args?: string[];
  };
}

export class PDFEngineFactory {
  private static instance: PDFEngineFactory;
  private config: EngineConfig;

  private constructor(config: EngineConfig) {
    this.config = config;
  }

  static initialize(config: EngineConfig): PDFEngineFactory {
    if (!PDFEngineFactory.instance) {
      PDFEngineFactory.instance = new PDFEngineFactory(config);
    }
    return PDFEngineFactory.instance;
  }

  static getInstance(): PDFEngineFactory {
    if (!PDFEngineFactory.instance) {
      throw new Error("PDFEngineFactory not initialized. Call initialize() first.");
    }
    return PDFEngineFactory.instance;
  }

  async generate(payload: any): Promise<Buffer> {
    if (this.config.strategy === "declarative") {
      return generateDeclarativePDF(payload);
    }
    return renderHTMLToPDF(payload);
  }

  async shutdown(): Promise<void> {
    if (this.config.strategy === "browser") {
      await teardownBrowserPool();
    }
  }
}

// Usage: Initialize once during app startup
// PDFEngineFactory.initialize({ strategy: "declarative" });
// const pdf = await PDFEngineFactory.getInstance().generate(invoiceData);

Quick Start Guide

  1. Install dependencies: Run npm install @react-pdf/renderer for declarative rendering, or npm install puppeteer for browser-based rendering. Add @sparticuz/chromium if deploying to serverless with Puppeteer.
  2. Initialize the engine: Import the factory module and call PDFEngineFactory.initialize() with your chosen strategy during application startup.
  3. Register fonts (Declarative only): Place required .ttf files in an assets/fonts/ directory. The factory will cache them automatically on first invocation.
  4. Generate a document: Pass your structured data or HTML template to PDFEngineFactory.getInstance().generate(). The method returns a Buffer ready for HTTP streaming or storage.
  5. Graceful shutdown: Call PDFEngineFactory.getInstance().shutdown() during server teardown to release browser connections and prevent resource leaks.