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/pdfRead 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.
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
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.
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"| BrA 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.