All notes

· 6 min read

Why the PDF renderer isn't a browser

The contact form on this site produces a branded PDF. The obvious way to render one is HTML plus headless Chromium, and I decided against it. Here's what each approach costs.

The decision

When you submit the contact form on this site, you get a branded A4 project brief back as a PDF. It uses the site's own dark palette, the panda mark, Sora and Inter, and a proportional timeline bar, and it renders inside a serverless function on request.

There's an obvious way to build that. You already have the markup, the CSS and the fonts, because the same brief renders on screen as you type. Point a headless browser at an HTML string, call page.pdf(), and you're done in an afternoon, with a document that looks exactly like the one in front of you.

I went the other way. I declare the document (its colours, its fonts, its layout) as data and render it with a library that has no browser in it. This note is my case for that, including the parts where it costs me.

What shipping a browser actually costs

I don't think framing it as "browser versus library" helps much. I care more about what each one drags into a function whose entire job is to return one page of A4.

A headless Chromium build for a serverless runtime is roughly 50MB of binary, typically shipped compressed and unpacked into the function's temp directory at startup. That means a cold start measured in seconds, on a route a visitor is waiting on, having just pressed a button. Warm invocations are fine, but this route is about as cold a path as you get, with one submission at a time, minutes apart, forever.

The second cost matters more to me. A download that works is now a download that depends on a browser binary being compatible with the runtime it was unpacked into, and that relationship breaks on a schedule you don't control. A Node version bump, a runtime image change, or a new shared library the base image dropped will do it. And the failure lands on the one route where failing is most expensive, because the person waiting for it has just told me about their project and is forming a view about whether I can build things.

Meanwhile the document itself is basically a masthead, a handful of typed sections, a table and a bar, so it isn't complicated. Rendering it doesn't need a layout engine capable of flexbox, floats, @media queries and a JavaScript runtime. It needs boxes, text and a font.

Declaring the document instead

So the brief is data. One pure module owns everything a brief says, and the renderer is a React component that turns that value into a fixed set of primitives (Page, View, Text, Image, Svg) through @react-pdf/renderer. The brand pack is read off disk at module load, with the palette coming from JSON, the mark as a PNG and the faces as .ttf files.

Font.register({
  family: "Sora",
  fonts: [
    font("Sora-SemiBold.ttf", 600),
    font("Sora-Bold.ttf", 700),
    font("Sora-ExtraBold.ttf", 800),
  ],
});

You'll notice there's no fetch in there. The brand pack names a Google Fonts URL, and using it would mean a network call on the request path of a route whose only job is to return a PDF. A third party's uptime would become this route's uptime, plus the latency, for files that never change. So the faces are vendored into the repository instead.

The route is explicit about one more thing.

export const runtime = "nodejs";

It runs on Node, not Edge, and that isn't negotiable. The renderer runs a layout engine and a font and PNG pipeline that need Node built-ins. On Edge it builds fine and then fails at 3am on the first real download.

What it costs me

First, you lose the browser's whole CSS model. No cascade, no media queries, no grid. You get a constrained flexbox-like layout and that's it.

You also hand-build the layout. A4 becomes two numbers, 595.28 by 841.89 points, and the background is drawn instead of laid out. Every padding and column width is a value someone chose. The on-screen brief and the PDF are two renderers over one data model, so a change to how the brief looks has to be made twice.

And you commit font binaries. There are eight .ttf faces in the repository, checked in as artefacts, each with a licence question attached. That's a real cost.

What I get back is no browser, no unpack step, a cold start dominated by the function itself, and a dependency surface I can read in an afternoon.

The font that wasn't a font

Vendoring the faces had one wrinkle worth passing on. My first attempt fetched them from Google Fonts and got back EOT (Embedded OpenType, the format Internet Explorer used), because the stylesheet endpoint serves a format chosen by the requesting user-agent and a bare script doesn't look like a modern browser. The PDF renderer can't read EOT. Registration fails, the fallback catches it, and the document renders in Helvetica.

The fix is a four-byte check before anything gets committed. A real TrueType file opens with sfntVersion 0x00010000.

Inter-Regular.ttf            00010000
Sora-Bold.ttf                00010000
JetBrainsMono-Regular.ttf    00010000

Anything else isn't a TrueType font, whatever the filename says. It takes a second to check. Without it, all you know is "the fetch worked". You don't know whether "the fetch returned the thing I asked for".

Files missing in production

The last piece is the one most likely to bite anyone reading this. public/ isn't traced into a serverless function, and neither is any directory nothing imports.

Next builds each function's bundle by following imports. A file read off disk at runtime (a font, a logo, a JSON pack) has no import to follow, so the tracer never sees it. Locally the whole repository is on disk and everything works. In production the file just isn't there, and because every read here is wrapped in a fallback, the document renders anyway, with no mark, fallback colours and Helvetica. The branding is gone, with no error, in a file that goes to a prospective client.

The fix is to declare it.

outputFileTracingIncludes: {
  "/api/brief": ["./brand/**"],
  "/contact": ["./brand/**"],
},

There are two entries because the same document is rendered by two different functions (one for the download, one for the copy attached to the email), and tracing is per-function. Miss the second one and the downloaded PDF is branded while the emailed one goes out without it.

Where the line sits

I'd reach for a headless browser when the document really is a web page, like arbitrary user HTML, a chart rendered by a JS library, or a layout that already exists only as CSS. Those exist, and the browser is the right tool for them.

But for a document I control end to end, rendered on a cold path, where I can describe the thing in data, I'd rather write the layout once than pay for 50MB of browser on every invocation.

Published pdf · serverless · build decisions