How rendering works

One mental model for the whole render path — so parallel rendering, compositions, lifecycle events, and asset serving stop feeling like separate features and start feeling like one machine.

The overview sketches the run-time flow in a few lines. This page zooms in on how the pieces actually fit together, so that the individual features — compositions, lifecycle events, assets — read as parts of a single pipeline rather than unrelated knobs. Each feature has its own how-to guide; here we only explain where it sits in the machine and why.

The one pipeline everything reuses

Every PDF, no matter how it was requested, is produced by the same core pass. A single POST /templates/<name>/render runs it once:

sequenceDiagram
  autonumber
  participant App as Your app
  participant Svc as PDF Server
  participant Int as Internal server<br/>(loopback only)
  participant Br as Pooled browser
  App->>Svc: POST /templates/name/render {data}
  Svc->>Svc: validate data against schema.json (422 on failure)
  Svc->>Svc: render body / header / footer HTML with the engine
  Svc->>Int: store rendered HTML → document id + internal URL
  Svc->>Br: borrow a browser from the pool, navigate to that URL
  Br->>Int: GET the HTML
  loop each referenced asset
    Br->>Int: GET assets/… (CSS, images, fonts)
    Int-->>Br: file bytes
  end
  Br->>Br: wait for the lifecycle event (default: load)
  Svc->>Br: printToPDF
  Br-->>Svc: PDF bytes
  Svc->>Br: return the browser to the pool
  Svc-->>App: application/pdf

Read the stages as four responsibilities:

  1. Validate — if the template ships a schema.json, the request data is checked against it first. Invalid data returns 422 and never reaches a browser. See Data validation.
  2. Render — the chosen engine turns the template plus your data into HTML for the body, and optionally the header and footer.
  3. Print — a headless browser loads that HTML, fetches whatever it references, waits for a lifecycle event, and prints to PDF.
  4. Return — the bytes come back as application/pdf.

Everything below is a variation on, or a detail of, this one pass. Nothing adds a second pipeline — compositions run this pass several times, and the pool runs several copies of it at once.

The developer-level trace of the same path, with package and function names, lives in Render pipeline.

Rendering happens in a real browser

The “print” stage is a genuine headless Chromium (or Firefox), driven over the Chrome DevTools Protocol. That is why the PDF matches what a modern browser would print — real CSS (flexbox, grid), web fonts, SVG, and JavaScript all work. It is also why rendering is the expensive stage: a browser holds memory and takes real wall-clock time per document. The next two sections are both consequences of that single fact.

Parallel rendering: the browser pool

PDF Server keeps a pool of ready browsers instead of launching one per request. A render borrows a browser, uses it, and returns it; the pool caps how many exist at once with --render-pool-max-total (RENDER_POOL_MAX_TOTAL).

Two things follow from the pool, and they trip people up if the model isn’t clear:

  • The pool size is your parallelism. The number of render workers equals the pool size. The default is 1, which means renders run one at a time — a second request waits for the first to finish (up to the borrow timeout). To render in parallel you must enlarge the pool; there is no separate “worker count” to raise.
  • Each render is isolated. A borrowed browser handles exactly one render at a time, then is returned (or, after --factory-max-usage-count renders, recycled for a fresh one). Renders don’t share page state.

How a browser maps to a pooled slot depends on --factory-type:

ModeA pooled slot is…Notes
runner (default)a whole browser processChromium and Firefox; strongest isolation — a crash takes down only that render.
pagea tab in one shared browser processChromium only; lower per-render startup cost, so better throughput.

Sizing the pool (and picking a factory mode) is an operational decision covered in Tuning; the pool’s internals are in CDP browser pool.

Compositions: the pipeline, run several times and merged

A composition doesn’t introduce a new rendering mechanism — it introduces a plan. A planner (jq or JavaScript) turns your request data into an ordered list of (template, data) tasks. Each task is then fed through the very same render pass described above, and the resulting PDFs are concatenated in plan order into one document.

flowchart LR
  D["Request data"] --> PL["Planner<br/>(jq / JavaScript)"]
  PL --> T1["Task 1<br/>template + data"]
  PL --> T2["Task 2<br/>template + data"]
  PL --> T3["Task 3<br/>template + data"]
  T1 --> R["render pass<br/>(shared browser pool)"]
  T2 --> R
  T3 --> R
  R --> M["Merge PDFs<br/>in plan order"]
  M --> OUT["Single PDF"]

Because each task is an ordinary render, the two facts from the previous section apply directly:

  • The tasks are scheduled concurrently, but how many actually run at once is bounded by the same pool — with the default pool of 1 a three-task composition still renders sequentially. Enlarging the pool is what makes a composition’s parts render in parallel.
  • A task can be any template type — an HTML template, a raw pre-built PDF, or a remotely-fetched PDF — because “render this task” is just the normal pipeline.

The merge (via pdfcpu) is the only step unique to compositions; a single-task plan skips it. You can inspect the plan without rendering anything via preview/render-plan — see the compositions guide.

Lifecycle events: when the browser prints

Between “the browser finished loading the HTML” and “print to PDF” there is a decision: how long to wait. That gap is the lifecycle event. By default PDF Server prints on load — once synchronous resources are in and laid out. But if your page draws a chart, fetches data, or loads web fonts asynchronously, load fires too early and the PDF captures a half-built page.

This is the one place in the pipeline you control timing rather than content:

  • Wait for a later browser event (networkIdle, firstMeaningfulPaint, …) when “done” is approximately “the network went quiet”.
  • Use the callback event when only your page knows it’s ready: the render blocks until the page itself calls back to the internal server (the same loopback server that served its HTML), then prints immediately.

Either way the wait is bounded by the render timeout, so a page that never signals ready fails rather than hangs forever.

How the page reaches its assets

The browser never reads your template directory from disk. Instead, the rendered HTML is handed to a small internal HTTP server bound to 127.0.0.1 on a random port — never exposed to the outside network — and the browser navigates there. When the HTML references assets/styles.css or assets/logo.svg, those requests go back to that same internal server, which streams the file straight from the template’s assets/ directory.

flowchart LR
  subgraph Loopback["127.0.0.1 — never public"]
    Int["Internal server"]
  end
  Br["Headless browser"] -->|"GET the HTML"| Int
  Br -->|"GET assets/styles.css"| Int
  Br -->|"GET assets/logo.svg"| Int
  Int -->|"file bytes from the template's assets/ dir"| Br

A few consequences worth internalizing:

  • Assets are body-only. The header/footer HTML is passed to the browser’s own print machinery and cannot fetch from assets/. Inline anything a header or footer needs.
  • Paths are sandboxed to the template’s assets/ directory — a page can’t traverse out of it.
  • It’s loopback, not the internet. Asset fetches never leave the container, so they’re cheap and private — but a page that references an external URL will make a real outbound request, which is exactly what the lifecycle-event wait and the security model are about.

Full authoring detail is in the Assets guide; the server itself is described in Internal callback server.

Caching of assets: what is (and isn’t) reused

This is the part most people guess wrong, so it’s worth stating precisely.

The internal server does emit a caching signal, but a weak one. Each asset is served with a Last-Modified header (from the file’s modification time) and supports conditional (If-Modified-Since) and range requests. It does not set Cache-Control/max-age, so nothing is cached beyond a browser process’s own default in-memory HTTP cache.

Whether an asset is actually reused therefore depends on the factory mode:

Factory modeBrowser lifetimeAsset caching across renders
runner (default)a fresh process with a throwaway profile per rendernone — every render re-fetches every asset from scratch
pageone process reused across renders (until a tab is recycled after --factory-max-usage-count renders)the process’s in-memory HTTP cache can serve repeat fetches within that window

So on the default runner mode there is no cross-render asset cache at all — and that is fine, because the asset server is in-process and loopback-only, so re-fetching is nearly free. Within a single render, a browser still fetches each distinct asset once and reuses it across elements of that page as usual.

If you want a guaranteed single fetch and a fully self-contained document, inline the assets instead of referencing them. The embed helpers (embed_text, embed_base64) put the bytes directly into the HTML, so the browser makes no second request at all — useful for critical CSS, small logos, and fonts you never want to see a separate request for.

Putting it together

  • One request → one pass: validate → render HTML → serve on the loopback server → browser loads, fetches assets, waits for its lifecycle event → print → return.
  • Compositions run that pass once per planned task and merge the PDFs.
  • The browser pool decides how many passes run at once; its default of 1 means “no parallelism until you enlarge it”.
  • Lifecycle events decide when within a pass the browser prints.
  • Assets are fetched from the loopback server per render (re-fetched each time on the default runner mode); inline them when you want them baked in.

See also