How rendering works
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:
Read the stages as four responsibilities:
- Validate — if the template ships a
schema.json, the request data is checked against it first. Invalid data returns422and never reaches a browser. See Data validation. - Render — the chosen engine turns the template plus your data into HTML for the body, and optionally the header and footer.
- Print — a headless browser loads that HTML, fetches whatever it references, waits for a lifecycle event, and prints to PDF.
- 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-countrenders, recycled for a fresh one). Renders don't share page state.
How a browser maps to a pooled slot depends on --factory-type:
| Mode | A pooled slot is… | Notes |
|---|---|---|
runner (default) | a whole browser process | Chromium and Firefox; strongest isolation — a crash takes down only that render. |
page | a tab in one shared browser process | Chromium 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.
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
1a 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
callbackevent 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.
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 mode | Browser lifetime | Asset caching across renders |
|---|---|---|
runner (default) | a fresh process with a throwaway profile per render | none — every render re-fetches every asset from scratch |
page | one 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
1means "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
runnermode); inline them when you want them baked in.
See also
- Overview — the short version of the flow.
- Compositions, Lifecycle events, Assets — the per-feature guides.
- Tuning — pool sizing and
pagevsrunner. - Render pipeline — the code-level trace.