reportgen
Back to all posts

Inside reportgen.io's Rendering Pipeline: Four Template Engines, One Browser Fleet (Part 2)

July 17, 2026

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

In Part 1 we walked through the overall architecture: a Go API, Temporal orchestration, and a two-tier worker system. This post zooms into the part you actually feel as a user — the pipeline that turns your template and JSON into a finished PDF.


TL;DR

  • reportgen.io supports four template engines: EJS, Handlebars, Go Templates, and raw HTML. The engine field on your request decides everything downstream.
  • Rendering is routed by language: EJS and Handlebars run on the Node.js worker; Go Templates and raw HTML run on the Go worker. One workflow, two task queues.
  • Rendered HTML is staged in object storage, not passed through the workflow — workflow payloads should carry references, not files.
  • Conversion happens in headless Chromium via Puppeteer, pooled with puppeteer-cluster so browser pages are reused instead of cold-started per request.
  • Concurrency is bounded end to end: the browser pool and the worker's activity slots share one limit, and Temporal's queue absorbs bursts upstream.
  • Render errors fail fast (no retries — your syntax error won't fix itself). Conversion errors retry with backoff (browser flakes usually do fix themselves).

Step 1: One Request, Four Engines

Every generation request names its engine:

{
  "engine": "handlebars",
  "template": "<h1>Invoice #{{number}}</h1><p>Total: {{total}}</p>",
  "data": { "number": "INV-2041", "total": "€1,240.00" }
}

The four engines cover four distinct audiences:

Engine Best for Runs on
ejs JavaScript teams that want logic in templates (loops, conditionals, plain JS) Node worker
handlebars Logic-light, designer-friendly templates with helpers and partials Node worker
gotempl Go shops that already think in text/template / html/template Go worker
raw You already have final HTML — skip templating entirely Go worker

The raw engine deserves a special mention: a huge share of real-world usage is "my app already produced the HTML, just make it a PDF." In that case the render step is a passthrough and the pipeline is effectively browser as a service.

Internally, the engine name maps to a Temporal activity — a named unit of work that a worker executes. The workflow looks up the activity for your engine and dispatches it. Adding a fifth engine someday means adding one map entry and one activity implementation; the workflow itself doesn't change.


Step 2: Routing Work Between Go and Node

Here's the part that makes the two-tier worker system click. Temporal workers subscribe to task queues, and activities are dispatched to a queue — not to a specific machine.

  • The Go worker listens on the main queue and implements gotempl and raw rendering, artifact uploads, and status updates.
  • The Node worker listens on a dedicated Node queue and implements ejs and handlebars rendering plus PDF conversion.

When your request says engine: "ejs", the workflow simply dispatches the render activity to the Node queue instead of the Go queue. That's the entire routing mechanism. No service-to-service HTTP calls, no internal load balancer, no service discovery — Temporal's task queues are the routing layer, and they load-balance across however many worker pods are subscribed.

This is also what makes scaling honest: if EJS rendering and conversion are the bottleneck, we scale Node workers. If it's orchestration and database work, we scale Go workers. Neither deployment knows or cares about the other.


Step 3: Why Rendered HTML Goes Through Object Storage

After rendering, the obvious next move would be to hand the HTML string directly to the conversion step. We don't — and the reason is a constraint worth knowing if you ever build on Temporal.

Temporal persists every activity input and output into workflow history. That's the durability magic — but it means payloads must stay small (Temporal enforces hard limits in the low-megabyte range, and you should stay well under them). A rendered report with inlined styles and embedded assets can get big fast, and stuffing files through workflow history is a reliability anti-pattern.

So the pipeline treats object storage as its hand-off medium:

  1. The render activity returns HTML to the workflow.
  2. A Go activity uploads that HTML to object storage under the report's ID.
  3. The conversion activity receives only a reference — a short-lived signed URL to the staged HTML.

Workflow history carries references; object storage carries files. Beyond the payload limits, this has a subtle benefit: the staged HTML is a durable, inspectable artifact. When a customer asks "why does my PDF look wrong," we can look at exactly the HTML the browser saw — after templating, with their data merged in.


Step 4: Conversion — a Real Browser, Pooled

Conversion is where most HTML-to-PDF services cut corners, because running browsers at scale is genuinely annoying. The shortcuts — wkhtmltopdf, headless WebKit forks, HTML parsers with a print stylesheet bolted on — all produce PDFs that almost match what you saw in Chrome. "Almost" is not a word you want in your invoice pipeline.

We run the real thing. The Node worker drives headless Chromium through Puppeteer:

  1. Open a page and navigate to the staged HTML's signed URL.
  2. Wait for the network to go idle — so your web fonts, images, and chart libraries actually finish loading before we print. This is why a Chart.js graph renders correctly in a reportgen PDF: the browser genuinely executed it.
  3. Print to PDF: A4 by default, with your margins and background-graphics settings applied (full options in the docs).

Pooling with puppeteer-cluster

Launching Chromium per request would add seconds of cold-start latency and brutal memory churn. Instead, each Node worker maintains a puppeteer-cluster pool: a fixed number of browser contexts that stay warm and process conversions off a queue.

The important design decision is that the cluster's concurrency and the Temporal worker's activity slot limit are the same number. That alignment is the backpressure mechanism:

  • The worker never accepts more conversion activities than the browser pool can actually execute.
  • Excess work doesn't pile up in process memory behind a busy browser — it stays queued in Temporal, durable and load-balanced, until any Node worker (this one or another pod) has a free slot.

Bursts are absorbed by the orchestration layer, which is built for it — not by Chromium, which very much is not. It also means "scale up conversions" is just "add Node worker pods": each new pod brings its own browser pool and starts pulling from the same queue.


Step 5: When Rendering Fails vs. When Conversion Fails

The pipeline treats its two failure families completely differently, and this distinction does a lot of quiet work for reliability:

Render errors are deterministic. If your EJS template references a variable that isn't in your data, it will fail identically on every attempt. So render failures are marked non-retryable: the workflow fails fast, and the report's error message carries the actual template error so you can fix it — no five rounds of retries on a syntax error, no vague "something went wrong".

Conversion errors are usually transient. A page crash, a navigation flake, a slow asset — production taught us these typically succeed on the next attempt. So conversion failures are retryable, and the workflow's exponential backoff handles them automatically. A genuinely permanent conversion failure exhausts its bounded retries and then errors the report with the underlying cause preserved.

The result: transient infrastructure noise is invisible to you, and real template bugs surface immediately with an actionable message. Part 3 covers the retry policies and terminal-state guarantees in detail.


What's Next

In Part 3, we cover the reliability layer: durable execution, the pattern that guarantees every report ends in a terminal state, how storage and delivery work, and how usage-based billing is wired into the same workflow system that generates your PDFs.


Frequently Asked Questions

Which template engine should I use with reportgen.io?

If your team writes JavaScript, use EJS (more logic in templates) or Handlebars (cleaner separation, designer-friendly). Go shops usually pick gotempl. If you already generate final HTML in your app, use raw and skip templating entirely.

Does reportgen.io support JavaScript, web fonts, and chart libraries in PDFs?

Yes. Conversion runs in headless Chromium, and we wait for the page's network activity to settle before printing — so Chart.js graphs, Google Fonts, Tailwind via CDN, and similar browser-executed content render correctly. See dynamic charts and visualizations for working examples.

Why does reportgen.io use Puppeteer instead of wkhtmltopdf?

wkhtmltopdf is built on an outdated WebKit fork with incomplete CSS support (flexbox and grid are notorious pain points). Puppeteer drives current Chromium, so PDFs match what you see in Chrome today — modern CSS included.

How does reportgen.io handle traffic spikes?

Concurrency is bounded at the browser pool, and everything beyond the bound queues durably in Temporal rather than in worker memory. Bursts queue gracefully and drain as capacity frees up or new worker pods come online — requests aren't dropped.

Can I see the exact HTML that was converted to PDF?

The pipeline stages the fully-rendered HTML (your template with data merged) as a durable artifact before conversion, which is a big part of how we debug rendering questions quickly when you contact support.


Curious how it holds up when things break? That's Part 3. Want to try the pipeline instead of reading about it? Grab a free API key — 150 PDFs a month, no credit card.