DEV Community

Cover image for I replaced a Puppeteer service with 39 lines of code
Dan Molitor
Dan Molitor

Posted on • Originally published at formepdf.com

I replaced a Puppeteer service with 39 lines of code

I had a separate Express server running headless Chrome just to generate PDFs. It was slow, expensive, and crashed under load. I replaced the entire thing with a single function call. Here's what happened.

The setup

I run a soil analysis API. Farmers submit samples, the API processes results, and generates a 4-page PDF report - cover page, nutrient analysis tables, product recommendations, the works.

The PDF pipeline looked like this:

  1. A 700-line function in pdf-generator.ts builds an HTML string — concatenating divs, inline styles, table rows, all of it
  2. POST that HTML to a separate Express server running Puppeteer
  3. Puppeteer launches headless Chrome, renders the HTML, calls page.pdf()
  4. Upload the PDF bytes to Vercel Blob
  5. Return the URL

It worked. Mostly.

The problems

It was slow. Each render took 1–5 seconds depending on Chrome's mood. Cold starts were worse.

It needed its own server. Chromium is 200MB+. Vercel functions have a 50MB bundle limit. So I ran a separate server just to host headless Chrome. That's a whole deployment, monitoring, and cost for one function.

It was fragile. Chrome processes would occasionally hang or crash. I had restart logic, health checks, timeout handling — infrastructure code that had nothing to do with making a PDF.

The HTML was a nightmare. 700 lines of string concatenation. No components, no reuse. Every change meant hunting through template literals for the right closing </div>. CSS print rules for page breaks were unreliable — content would split mid-row in tables.

The replacement

I wrote a JSX template:

export default function SoilReport(data) {
  return (
    <Document>
      <Page size="Letter" margin={{ top: 54, right: 54, bottom: 72, left: 54 }}>
        {/* Cover page */}
        <View style={styles.coverPage}>
          <Text style={styles.title}>{data.clientName}</Text>
          <Text style={styles.subtitle}>Soil Analysis Report</Text>
        </View>
      </Page>

      {data.samples.map(sample => (
        <Page size="Letter" margin={{ top: 54, right: 54, bottom: 72, left: 54 }}>
          <Fixed position="header">
            <ReportHeader data={data} />
          </Fixed>
          <Fixed position="footer">
            <ReportFooter />
          </Fixed>

          <StatsCards sample={sample} />
          <NutrientTable nutrients={sample.nutrients} />
          <Recommendations products={sample.products} />

        </Page>
      ))}
    </Document>
  );
}
Enter fullscreen mode Exit fullscreen mode

Components. Real page breaks. Headers and footers that repeat automatically. The layout engine handles pagination — if a recommendations section is too tall for the page, it splits cleanly and continues on the next page with the header intact.

The render call in my API route:

const doc = SoilReport(reportData);
const pdfBytes = await renderDocument(doc);
await put(`reports/${id}.pdf`, pdfBytes);
Enter fullscreen mode Exit fullscreen mode

That's it. No HTTP call to another server. No browser. The Rust engine compiles to WASM and runs in-process. The whole thing fits in a Vercel function.

The commit

14 files changed, 39 insertions(+), 1018 deletions(-)
Enter fullscreen mode Exit fullscreen mode

What got deleted:

  • pdf-generator.ts — 700 lines of HTML string building
  • The entire Puppeteer Express server
  • Chrome launch config, restart logic, health checks
  • The templates/ directory with HTML partials

What got added:

  • One JSX template file
  • Three lines wiring renderDocument() into the existing API route

The numbers

Puppeteer Forme
Render time 1–5 seconds 28ms
Bundle size 200MB+ (Chromium) ~3MB (WASM)
Infrastructure Separate server In-process
Page break control CSS break-before (unreliable) Layout engine (automatic)
Template format HTML string concatenation JSX components

When Puppeteer is still the right call

If you're rendering arbitrary web pages — screenshots, HTML emails with complex CSS, anything where you need a real browser — Puppeteer is the tool. It's a browser automation library and it's good at that.

But if you're generating structured documents — invoices, reports, statements, certificates — you don't need a browser. You need a layout engine that understands what a page is. That's a different problem.

The tool

This is Forme. It's open source — a Rust PDF engine with a React component layer. You write JSX, it renders PDFs. Page breaks work. I built it because I needed it.

npm install @formepdf/react @formepdf/core
Enter fullscreen mode Exit fullscreen mode

If you're running a Puppeteer service just to make PDFs, you might not need to.

Top comments (0)