reportgen
Back to all posts

How reportgen.io Is Built: The Architecture Behind Our HTML-to-PDF API (Part 1)

July 15, 2026

This is Part 1 of a 3-part series on how reportgen.io works under the hood.

Most API vendors treat their internals like a trade secret.

We think that's backwards. If you're going to put reportgen.io in your invoice pipeline, your compliance reports, or your customer-facing exports, you deserve to know exactly what happens between "I sent you HTML" and "I got a PDF back."

So we're writing it all down.


TL;DR

  • reportgen.io is an HTML-to-PDF API: you send a template (EJS, Handlebars, Go Templates, or raw HTML) plus JSON data, and you get back a pixel-accurate PDF rendered by a real browser engine.
  • The backend is a Go service compiled as a single binary that runs in two modes: an HTTP API server and a Temporal worker.
  • A separate Node.js worker handles JavaScript-native template rendering and PDF conversion with Puppeteer (headless Chromium).
  • Temporal orchestrates every generation as a durable workflow, so a crashed server never loses your report.
  • PostgreSQL stores report metadata, Redis caches hot reads, and finished PDFs live in S3-compatible object storage.
  • Every report is guaranteed to reach a terminal state — completed or error — never stuck in limbo.

What Is reportgen.io?

reportgen.io is a developer-first PDF generation service. The core contract is deliberately small:

  1. You POST a template and a JSON payload to our API.
  2. We render the template into HTML.
  3. A real headless browser converts that HTML into a PDF.
  4. You get a download link — synchronously if you want to wait, asynchronously if you don't.

That's it. No proprietary document format, no drag-and-drop designer you're forced to use, no "contact sales" wall. If your HTML renders in a browser, it renders with us.

We've written before about why we built it. This series is about how.


The Stack at a Glance

Concern Technology
API server & orchestration logic Go (chi router)
Workflow orchestration Temporal
Template rendering (EJS, Handlebars) Node.js
Template rendering (Go Templates, raw HTML) Go
HTML → PDF conversion Puppeteer (headless Chromium) via puppeteer-cluster
Primary database PostgreSQL
Caching Redis
PDF & artifact storage S3-compatible object storage
Dashboard Next.js
Billing Stripe (usage-based metering)
Error tracking Sentry

Nothing exotic. Every piece is boring, proven technology — which is exactly what you want underneath a document pipeline.


The High-Level Architecture

 Your app / dashboard
        |
        v
 +---------------+        +------------------+
 |   Go API      |------->|    Temporal      |
 |  (HTTP, chi)  | starts |  (orchestration) |
 +---------------+ workflow +----------------+
        |                    |            |
        v                    v            v
 +------------+      +-----------+  +-------------+
 | PostgreSQL |      | Go worker |  | Node worker |
 |  + Redis   |      | (render,  |  | (render,    |
 +------------+      |  upload,  |  |  Puppeteer  |
                     |  status)  |  |  convert)   |
                     +-----------+  +-------------+
                            \          /
                             v        v
                        +----------------+
                        | Object storage |
                        |  (your PDFs)   |
                        +----------------+

Three moving parts do the real work:

  1. The Go API server authenticates the request, persists a report record, and starts a Temporal workflow. It does not render anything itself — its job is to accept work fast and hand it off.
  2. The Go worker executes workflow logic and Go-native activities: Go Template rendering, raw HTML passthrough, uploading artifacts to object storage, and updating report status in PostgreSQL.
  3. The Node.js worker executes the JavaScript-native activities: EJS and Handlebars rendering, plus the big one — driving headless Chromium to produce the actual PDF.

Temporal sits in the middle as the source of truth for in-flight work. PostgreSQL is the source of truth for finished work.


One Binary, Two Modes

The entire Go backend compiles to a single binary with two mutually exclusive flags:

# Run the HTTP API server
./reportgen -api
 
# Run the Temporal worker
./reportgen -worker

Both modes share the same domain models, repositories, and service layer — they only differ in initialization. This gives us a few things for free:

  • No drift. The API and the worker can never disagree about what a "report" is, because they're compiled from the same code at the same commit.
  • Trivial deployment. One image, one build pipeline, two run commands.
  • Independent scaling. API pods scale on request volume; worker pods scale on queue depth. They're the same artifact but scale on completely different signals.

The Life of a PDF Request

Here's what actually happens when you call the API:

curl -X POST https://reportgen.io/api/v1/generate-pdf-async \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "engine": "handlebars",
    "template": "<h1>Invoice #{{number}}</h1>",
    "data": { "number": "INV-2041" }
  }'
  1. Authentication. The request is authenticated via API key (or a dashboard session token if you're clicking around the UI).
  2. Validation & persistence. We validate the payload, create a report record in PostgreSQL with status pending, and check it against your plan.
  3. Workflow start. The API starts a GeneratePDFWorkflow in Temporal and — in async mode — immediately returns the report ID. Your HTTP request is done in milliseconds.
  4. Render. Temporal dispatches a render activity. Based on the engine field, it lands on either the Go worker (gotempl, raw) or the Node worker (ejs, handlebars). The output is fully-formed HTML.
  5. Stage the HTML. The rendered HTML is uploaded to object storage. (Why not pass it directly to the next step? Part 2 covers this — it's one of the more interesting design decisions in the pipeline.)
  6. Convert. The Node worker picks up a conversion activity, loads the staged HTML in headless Chromium via a short-lived signed URL, waits for the page to settle, and prints it to PDF.
  7. Store. The finished PDF is uploaded to object storage under your report's ID.
  8. Finalize. The workflow marks the report completed (or error, with a human-readable reason) and schedules a post-generation workflow that handles usage counting and billing.

The synchronous endpoint (/generate-pdf-sync) runs the exact same workflow — the API just waits for the result before responding, so you get the PDF link in one round trip.


Why Temporal Instead of a Job Queue?

The obvious architecture for this product is "API + Redis queue + workers." We went with Temporal instead, and it's probably the single best infrastructure decision we made.

A PDF generation is a multi-step process: render → stage → convert → upload → finalize → bill. With a plain job queue, every step boundary is a place where a crashed worker, a deploy, or a network blip can strand the job — and you end up hand-rolling state machines, retry bookkeeping, and reconciliation crons to paper over it.

Temporal gives us that machinery as a primitive:

  • Durable execution. Workflow state is persisted at every step. If a worker dies mid-generation, another worker resumes from the last completed activity — not from scratch.
  • Per-activity retry policies. Transient failures (a storage hiccup, a browser flake) retry automatically with exponential backoff. Deterministic failures (your template has a syntax error) fail fast and don't retry at all — retrying a syntax error five times just wastes everyone's time.
  • Cross-language task routing. Go activities and Node activities live in the same workflow, on separate task queues. The workflow doesn't care which language executes a step.
  • Full history. Every workflow execution has a complete, inspectable event history. When something behaves oddly, we can replay exactly what happened — not grep logs and guess.

We'll go deep on the reliability patterns this enables in Part 3.


Why Split Go and Node.js?

Because each is the honest best tool for its half of the job:

  • Go is superb for the API layer, database access, and orchestration glue: one static binary, tiny memory footprint, great concurrency.
  • Node.js is where the browser ecosystem lives. Puppeteer is the canonical way to drive Chromium, and EJS/Handlebars are JavaScript templating engines — rendering them anywhere else means emulating JavaScript semantics badly.

The tempting shortcut is to do everything in one runtime. But Chromium is a resource-hungry neighbor — you don't want it sharing a process (or a failure domain) with your API server. Splitting the workers means a misbehaving conversion can never take the API down with it, and we can scale browser capacity independently of everything else.


What's Next

In Part 2, we follow a template through the rendering pipeline: how the four templating engines work, how work is routed between the Go and Node workers, how Puppeteer is pooled with puppeteer-cluster, and why large artifacts travel through object storage instead of through the workflow itself.


Frequently Asked Questions

What technology stack does reportgen.io use?

Go for the API server and orchestration, Node.js for browser-based work, Temporal for workflow orchestration, Puppeteer (headless Chromium) for PDF conversion, PostgreSQL for data, Redis for caching, S3-compatible object storage for PDFs, Next.js for the dashboard, and Stripe for usage-based billing.

Does reportgen.io use a real browser to generate PDFs?

Yes. Every PDF is printed by headless Chromium via Puppeteer. Your CSS, web fonts, and charts render exactly as they would in Chrome — because it is Chrome.

What templating engines does reportgen.io support?

Four: EJS, Handlebars, Go Templates, and raw HTML. See the engine documentation for syntax details and examples.

Is PDF generation synchronous or asynchronous?

Both. /generate-pdf-sync waits and returns your PDF link in one request; /generate-pdf-async returns immediately with a report ID you can poll or list from the dashboard. Same pipeline, same reliability guarantees.

Can a report get stuck in "pending" forever?

No — the workflow is designed so every report reaches a terminal state, completed or error, on every code path. Part 3 of this series explains exactly how that guarantee is engineered.


Want to see the pipeline from the outside first? Get a free API key — 150 PDFs a month are on us, no credit card required.