Performance and operations
Running the engine inside someone else's application for a long time. Everything here is off or conservative by default, because the failure mode of a report engine is not slowness — it is a document that is wrong, or a host that falls over on a Tuesday afternoon.
Where the time goes
One generation is four stages, and they are not remotely equal:
| Stage | Typical | What makes it slow |
|---|---|---|
| Load template | microseconds after the first call | cached until the file changes |
| Bind data | milliseconds | a template with thousands of {{#each}} iterations |
| Render | hundreds of milliseconds to seconds | the browser: layout, fonts, charts, awaitsReadySignal |
| Compose | milliseconds for PDF, longer for Word and PowerPoint | rasterizing every page at ImageDpi |
The browser dominates. Optimising anything else first is wasted effort — the numbers are on
every generation through IGenerationObserver (below), so measure rather than guess.
Two things are already cached and need no configuration:
- Templates, keyed by file length and last-write time. A changed file is picked up on the next call without a restart.
- Compiled bindings, keyed by the template's content hash. Handlebars compilation happens once per template version, not once per report.
The output cache
Off by default. When a report is a pure function of the JSON it is given, this skips the browser entirely for a repeat request:
services.AddParagon(options =>
{
options.OutputCacheEntries = 100; // 0 disables it — the default
options.OutputCacheTtl = TimeSpan.FromMinutes(10); // TimeSpan.Zero keeps entries until evicted
});
The key is hash(templateHash + format + canonical JSON), so:
- Publishing a new template version invalidates everything from the old one automatically — the hash is of the content.
- Property order and whitespace in the payload do not matter; two clients spelling the same data differently hit the same entry.
- Array order does matter. It is the meaning of an array, and two orders are two reports.
- Data that is not valid JSON is not cached at all — binding raises the real error instead.
IParagonEngine.Refresh()clears it, because the templates it came from may have changed.
When not to turn this on
A report that is not a pure function of its input will serve a stale document and look correct doing it. That covers more templates than people expect:
- anything printing "generated at" or today's date from the browser's clock
- a document number or sequence the template invents
- anything reading from an asset that changes without the template changing
If in doubt, leave it off. A cache that returns yesterday's invoice is a worse problem than a slow endpoint. The size limit is on entries, not bytes — a report is megabytes, so pick a number with that in mind: 100 entries of a 3MB PDF is 300MB.
Concurrency and the browser
options.MaxConcurrentRenders = 4; // how many reports may render at once
options.MaxRendersPerBrowser = 500; // relaunch after this many; 0 keeps one browser forever
options.RenderTimeout = TimeSpan.FromSeconds(60);
One browser serves the whole process, with a fresh context per render so nothing — cookies,
storage, state — carries between reports. MaxConcurrentRenders is the real throttle: each
in-flight render holds a page, and pages cost memory. Start at 4 and raise it while watching memory
rather than latency; the ceiling is usually RAM, not CPU.
The browser is replaced after MaxRendersPerBrowser renders, because Chromium's memory grows
slowly across a long life — invisible in a test run, obvious in a host that has been up for a
fortnight. The replacement waits for renders in flight: a busy engine carries on and recycles at
the next quiet moment. That trade is deliberate, and pinned by a test — closing a browser out from
under a live render would turn a memory precaution into a failed report.
Do not hold a request thread on a render
A render is hundreds of milliseconds at best, and RenderTimeout long at worst. Under load that
starves the host's thread pool, and the first symptom is unrelated endpoints timing out.
For anything interactive, queue it: accept the request, hand back an id, generate on a worker,
and let the client collect the file. System.Threading.Channels with a small number of consumers
is enough — the engine is thread-safe and the semaphore already bounds what actually runs.
Always pass the caller's CancellationToken through. A client that goes away should stop costing
you a browser page.
Telemetry
Register an observer before AddParagon, and every generation reports itself:
public sealed class ReportMetrics(ILogger<ReportMetrics> logger) : IGenerationObserver
{
public void OnGenerated(ReportGenerationInfo info) =>
logger.LogInformation(
"{ReportCode} {Format} {PageCount}p {SizeBytes}b total={TotalMs}ms " +
"render={RenderMs}ms cached={FromCache} correlation={CorrelationId}",
info.ReportCode, info.Format, info.PageCount, info.OutputSizeBytes,
info.Duration.TotalMilliseconds, info.RenderDuration.TotalMilliseconds,
info.FromCache, info.CorrelationId);
public void OnFailed(string reportCode, ReportOutputFormat format, Exception exception) =>
logger.LogError(exception, "{ReportCode} {Format} failed", reportCode, format);
}
services.AddSingleton<IGenerationObserver, ReportMetrics>();
services.AddParagon(...);
ReportGenerationInfo carries Duration, BindDuration, RenderDuration, ComposeDuration,
PageCount, OutputSizeBytes, TemplateVersion, TemplateHash, FromCache, CorrelationId and
BlockedRequests.
Worth alerting on:
BlockedRequestsnot empty — the template reached for something outside itself. It rendered, but a font or a script did not load, so the document is probably wrong. This is the one to watch.FromCacheunexpectedly high — a template that should vary is not varying.RenderDurationclimbing over days — the browser recycle interval may be too long.
The engine also logs on its own ILogger<ParagonEngine> at Information (one line per report,
with the stage breakdown), Warning (blocked requests) and Error (failures). Every line carries a
scope with ReportCode and CorrelationId — taken from the ambient Activity when the host has
tracing on, so a report lands in the same trace as the request that asked for it.
No logging configuration is required: in a bare ServiceCollection the engine resolves a null
logger rather than failing. A host that calls AddLogging() before AddParagon() keeps
its own — both registrations are TryAdd, so the first one wins.
Sizing, roughly
Starting points, not promises — measure on your own templates:
| Setting | Start at | Raise when |
|---|---|---|
MaxConcurrentRenders |
4 | queue depth grows and memory is comfortable |
MaxRendersPerBrowser |
500 | never; lower it if memory climbs over days |
RenderTimeout |
60s | a legitimate template genuinely needs longer |
OutputCacheEntries |
0 | the same report and data are asked for repeatedly |
The two settings that most often need attention are ImageDpi (a Word or PowerPoint file is one
image per page — 100 is usually plenty, 200 quadruples the bytes) and MaxConcurrentRenders. DPI is
a host setting rather than a per-report one: two reports printing at different sharpness on the same
deployment is a bug report waiting to happen, and PDF output is unaffected either way — its text
stays vector.