How I ended up running real React 18 inside a single Go binary — and why it made a painful feature boring.
Every backend eventually grows the feature nobody volunteers for: generate a PDF. An invoice, an offer letter, a statement. It sounds trivial until you ship it, and then you learn that server-side PDF generation is one of the more miserable corners of the job.
I got tired of the misery and built waffle. You author documents in React; you render them to PDF from inside your Go process — no Node, no headless Chromium, no sidecar. One static binary. This is the short version of why and how.
Three miserable camps
If you need a good-looking PDF on a server, you pick a camp — and they all hurt.
Render HTML in a browser (Puppeteer, Gotenberg). The output is gorgeous because it's a real browser — and now you're running a browser on your server: a few hundred MB of Chromium, a separate process to deploy and scale, a network hop per document, a cold start after every deploy. You wanted a PDF; you inherited an ops problem.
Use a drawing-API library. Tiny and fast, but you place everything by hand — drawText(x, y), manual column math. No flexbox, no components, no "this table has a variable number of rows." Every layout change is arithmetic.
Adopt a templating engine. A step up from arithmetic — the library ships its own template language, so you describe the document in its tags and feed it data. But now you're learning someone's private dialect: its own loops and conditionals, its own styling rules, its own escaping quirks — JasperReports' JRXML, a bespoke {{tag}} syntax, pick your flavor. The knowledge transfers nowhere, the tooling catches nothing until runtime, and you hit a ceiling the moment the document needs real logic. You don't compose components; you memorize a manual.
Three flavors of one tax: the first bills you in ops, the second in arithmetic, the third in a language you'll never use anywhere else. I didn't want to pay any of them.
The model I actually wanted
There's a project I love — react-pdf — that lets you write a PDF the way you write a UI:
<Document>
<Page size="A4" style={{ padding: 40 }}>
<Text style={{ fontSize: 24 }}>Invoice</Text>
<View style={{ flexDirection: 'row' }}>{/* ... */}</View>
</Page>
</Document>
Real components, real props, StyleSheet, flexbox — because it is React. That's the authoring model I wanted.
And it isn't really react-pdf's model — it's React Native's. React Native proved React was never about the browser: it just describes a tree, and you can point that tree at any host you write a renderer for — native views on a phone, built from View/Text/Image and laid out with flexbox. react-pdf aimed those exact primitives at a sheet of paper; waffle takes it one step further and runs the whole thing inside Go.
The catch with react-pdf: it runs on Node. My backend is Go, so "just use react-pdf" means standing up a Node process next to my Go one and talking over a socket. That's the browser camp again in a nicer shirt.
So the question that became waffle was blunt: can I keep react-pdf's authoring model but throw away the runtime, and render entirely inside my Go process?
The idea: run real React, inside Go
The trick leans on two mature, pure-Go projects:
-
esbuild transpiles and bundles the JSX in memory — no
node_modules. - goja is a JavaScript engine written in Go that runs real, unmodified React 18.
Put them together and something almost illicit happens: waffle compiles your JSX in memory and executes genuine React on a JS engine that is itself just a Go library. Components render, hooks fire — all inside the process serving your requests. React and waffle's runtime are baked into the binary with go:embed, so a document that does import { View, Text } from '@waffle/react' builds and runs with nothing installed. CGO_ENABLED=0, one static binary.
The whole engine is two words: compile once, render many.
JSX ──esbuild──▶ bundle ──goja/React──▶ element tree ──flexbox──▶ pages ──paint──▶ PDF
(compile once, ~4 ms) (render many, milliseconds each)
You compile a template once at startup (~4 ms) and render it per request — cheap, concurrent-safe, one fresh VM each time.
The whole thing in one picture
Before I walk the stages, here's the full architecture — the compile-once lane, the render-many lane, the JSON contract that splits the JavaScript world from the Go world, and the per-page callback loop back into the VM:
The compile-once lane, the render-many lane, the JSON contract between JavaScript and Go, and the per-page callback loop back into the VM.
Keep it in the corner of your eye for the next few sections — everything below is just a walk through this diagram, left to right.
The realization that makes it simple
Here's the part I like most, because a hard problem quietly becomes an easy one.
React is normally a reconciler: render, wait for state and effects, re-render, diff, commit — an endless stateful dance with a screen. That's exactly what makes "React on a server" sound scary.
But a document isn't a UI. A document is a pure function of its props. Same invoice data, same invoice — every time, no clicks, no timers, nothing to reconcile. So waffle installs its own tiny hooks dispatcher and renders in one synchronous pass: useState returns its initial value, useEffect never fires, useMemo just runs. It's the actual React package rendering your actual components — captured at the one instant that matters for a file. Because of that, most existing react-pdf documents run on waffle as-is.
When that pass finishes, the JS side doesn't draw anything. It emits plain JSON describing the tree — boxes, styles, fonts. That JSON is the border between the two worlds: everything above it is JavaScript, everything below is Go. (Nice side effect: if you already have a tree in that shape, you can render it with no JavaScript at all — the fastest path in the system.)
Below the JSON line, it's all Go
From there it's a straightforward assembly line, each stage doing one job:
styles resolve units, colors, and inheritance → layout runs a real flexbox engine (grow, shrink, wrap, percentages, aspect-ratio) and measures text with true font metrics → pagination slices the flow into pages, splitting tables at real boundaries, keeping fixed headers, honoring orphans and widows → paint turns boxes into PDF content streams → the writer emits the actual file: compressed streams, embedded fonts, encryption, the byte-exact xref table.
Two details are where "looks right" is won: layout is real flexbox, so flexDirection: 'row' behaves like it does in a browser; and text is measured, not guessed — real glyph advances from the embedded font — which is what makes justification and ellipses land on the right pixel.
And page numbers? You don't know it's "page 3 of 8" until pagination runs. So waffle keeps those render={({ pageNumber }) => ...} functions alive in the VM and calls back into it once the Go side knows the real numbers. The two runtimes have a short conversation exactly when they need one.
How fast it actually is
Speed comes from one design choice: a template caches everything render-invariant. The letterhead PNG is decoded, the fonts parsed and compressed, exactly once — then reused by every render. What's left per document is only the work that genuinely changes: run React, lay out, paint. And because each render runs on its own VM, you scale the obvious way — plain goroutines across your cores.
On an M4 Pro with a warm template:
- 1-page report: 6.6 ms serial, 3.0 ms across cores
- same doc from pre-serialized JSON (no JavaScript): 1.9 ms — the pure-Go floor
- 8-page letter with a full letterhead and four fonts: 24 ms
And with no browser to amortize, it stays civil under tight limits: on 0.25 of a vCPU / 384 MiB, the 8-page letter holds a p50 near 100 ms at 83 MiB peak — where a browser/LibreOffice sidecar on the same box is in the low seconds and hundreds of MB. That gap isn't cleverness; it's the whole thesis.
The honest part
Every architecture is trade-offs in a trench coat, so — plainly:
- It's not a browser. You get the react-pdf surface (flexbox, text, images, SVG, a canvas escape hatch), not arbitrary HTML/CSS. Refusing the browser is why the footprint is what it is.
-
The render is synchronous and pure. You can't
fetchmid-document; you fetch first and pass data as props. For producing a file, that determinism is a feature. - Assets are read once per template. The cache that makes it fast also means you load a new template to pick up a changed file.
I chose all three. They're the price of one static binary that lays out real documents in single-digit milliseconds.
Why "boring" was the goal
When a document is just a function of props running inside your own process, the whole PDF subsystem gets boring in the best way. One binary to deploy. Nothing to health-check on the side. You scale it like everything else in your Go service, and you test it like everything else, because the output is deterministic and every generated file is strict-validated against a real PDF validator.
I set out to keep react-pdf's authoring model and delete its runtime. What I got back was a week's worth of ops complexity vanishing from a feature that used to eat one. The React is real; the Go is boring; the PDF comes out the other side.
waffle is MIT-licensed at github.com/SwishHQ/waffle.

Top comments (0)