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 | rasterising every page at 300 dpi |
The browser dominates. Optimising anything else first is wasted effort — the numbers are on
every generation through IGenerationObserver (below), so measure rather than guess.
Excel loads but does not print. It goes through the same render stage as everything else — the page has to be laid out before anything can be said about what a cell looks like — and then reads the tables instead of printing them. So the browser still dominates, and the saving is the print and the rasterisation rather than the render: no PDF is produced and no page is drawn at 300 dpi, which is most of what compose costs elsewhere.
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 each page's content hash. Handlebars compilation happens once per version of a page, 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; // pages that may render at once, process-wide
options.MaxConcurrentPagesPerReport = 2; // how many of those one report may hold
options.MaxRendersPerBrowser = 2000; // relaunch after this many pages; 0 keeps one 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 page costs memory. Start at 4 and raise it while watching memory rather than latency; the
ceiling is usually RAM, not CPU.
A report's pages render concurrently, which is what MaxConcurrentPagesPerReport bounds.
Without it a five-page deck takes every slot and every other report queues behind it.
The browser is replaced after MaxRendersPerBrowser pages — counted in pages, not reports,
because a page is what gets printed — since Chromium's memory grows slowly across a long life. The
replacement waits for renders in flight, so a busy engine carries on and recycles at the next quiet
moment.
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 |
MaxConcurrentPagesPerReport |
2 | reports are many-paged and rarely concurrent |
MaxRendersPerBrowser |
2000 | 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 |
MaxConcurrentRenders is the one that most often needs attention. Rasterisation DPI is not a
setting: Word and PowerPoint hold one image per page, drawn at 300, and two deployments disagreeing
about how sharp their documents are is a bug report waiting to happen. PDF output is unaffected
either way — its text stays vector, and Excel holds no images at all, so it is the one format the
setting could never have applied to.