HTML to PDF Rendering Modes and the Persistent Rendering Engine
The HTML to PDF and HTML to Image converters render the HTML pages in a separate process, the HTML rendering
engine, which is a headless Chromium browser shipped with the library. The library manages this process for
the application and offers two ways of doing it, called rendering modes. The mode is a global setting of the
library and applies to every HTML conversion of the application, including the conversions of the HTML templates
used for headers and footers.
This topic describes the two modes, when to use each one, the settings that control the persistent process
and how the converter performs in each mode.
The rendering mode is selected with the GlobalSettingsHtmlRendererMode
property, which takes one of the two values of the HtmlRendererMode enumeration.
Mode | What happens |
|---|
PersistentProcess (default) |
One rendering engine process is started at the first conversion and kept running for the application.
Each conversion runs in its own browser and its own private context inside that process, with its own
cookies, cache and storage, so conversions share nothing but the process and the engine startup is paid once.
|
ProcessPerConversion |
A rendering engine process is started for each conversion and exits when the conversion completes.
Nothing stays resident between conversions and each conversion pays the process startup.
|
The mode can be changed at any time. Each conversion uses the value read at its start and the conversions
in progress are not affected. Switching from the persistent process to a process per conversion does not
stop a running persistent process: it stops by itself after the idle timeout described below, or at once when
the application calls StopPersistentRenderer.
// The default: one rendering engine process for the application
GlobalSettings.HtmlRendererMode = HtmlRendererMode.PersistentProcess;
// A rendering engine process for each conversion
GlobalSettings.HtmlRendererMode = HtmlRendererMode.ProcessPerConversion;
The persistent process is the right mode for an application that converts more than once: web applications,
services, batch jobs and command line tools that process many documents. The first conversion starts the
engine and every conversion after it starts in a few milliseconds. Under sustained parallel load, the case it
was designed for, it converts more documents per second than a process per conversion on the same hardware
and holds that rate over runs of thousands of documents.
A process per conversion is the right mode when no process may stay resident between conversions or when each
conversion must run in its own operating system process: a memory constrained host, a desktop application
that converts rarely and must not keep the engine in the background, a platform that terminates background
processes, or untrusted pages whose failure must not be able to touch any other conversion. It is also the
natural choice for a process that makes a single conversion and exits, where the two modes cost the same.
In both modes each conversion is isolated from the others: its own browser, its own cookies, cache and
storage, its own rendering process for the page. The difference between the modes is the process that hosts
the browsers, not the isolation of the pages. In both modes the converter works on machines with no
outbound internet access.
The persistent process is started by the first conversion of the application, or earlier by calling
StartPersistentRenderer(String),
so that the first conversion of a web application does not pay the engine startup. It stops when the
application exits, when the application calls
StopPersistentRenderer
or after a period without conversions. The conversions in progress always complete before the process stops.
If the application terminates abnormally, the engine process terminates with it.
The settings of the persistent process are grouped in the
GlobalSettingsPersistentRenderer property:
Setting | Description |
|---|
StartTimeoutSeconds |
How long the library waits for the process to start before it fails the conversion. The first start on a
machine reads the engine from disk and can take much longer than the following ones. On the consumption
plans of the cloud providers it can exceed a minute. The default is 90 seconds.
|
IdleTimeoutSeconds |
The process stops after this many seconds without conversions and the next conversion starts a new one.
The default is 300 seconds. 0 keeps the process running for the lifetime of the application. Applies
immediately, without restarting the process.
|
MaxConversions |
After this many conversions the next conversion starts a new process. The old process finishes its
conversions in progress and exits. 0, the default, disables the rule. Applies immediately.
|
MaxLifetimeMinutes |
The same rule, by the age of the process. 0, the default, disables it. Applies immediately.
|
RetryAfterUnexpectedExit |
When the engine process exits unexpectedly, a conversion in progress is retried once on a new process.
Enabled by default. When disabled the conversion fails with an exception that includes the exit code of
the process.
|
GpuRenderingEnabled, GpuCompositingEnabled, EnableSoftwareGpuRendering, DisableSiteIsolation, DisableWebSecurity, AllowInsecureContent, IgnoreCertificateErrors, EnableElevatedRendering |
Settings of the engine process itself, given to the process when it starts. A change replaces the running
persistent process at the next conversion. In the process per conversion mode the same settings are
taken from the properties of each converter object.
|
The recycling rules are not needed for correctness: the memory of the process grows during the first few
hundred conversions while the engine fills its caches and then stays flat. They exist for the deployments
that want a hard bound on the age or on the use of a process.
Conversions can run in parallel from several threads in both modes. The
GlobalSettingsMaxParallelConversions
property limits how many conversions run at the same time and the conversions above the limit wait for a free
slot. Every page loaded in the engine counts, the HTML templates of headers and footers included, so a
conversion with an HTML header and footer passes the limit three times, one load after another.
The default is the number of logical processors of the machine, kept within a minimum and a maximum. It is
the value that gives the highest throughput: throughput grows with the number of parallel conversions up to the number
of logical processors and falls beyond it, when the loads contend for the same cores. A value equal to the
number of physical cores gives close to the same throughput with a lower latency per conversion. Each conversion in progress needs about 150 to 200 MB of memory in the
engine, on top of the resident engine process, which grows from about 300 MB at start to 0.5 to 1 GB once
its caches are filled, after the first few hundred conversions.
MaxParallelConversions must be set before the first conversion of the application.
A change made after the first conversion is ignored.
The GlobalSettingsPersistentRendererStatus
property returns the state of the persistent process: whether it runs, its process id, when it started, the
conversions sent to it and in progress, how many times it was replaced and why and the last error of a
failed start. It is cheap to read and meant for a health check endpoint.
The GlobalSettingsPersistentRendererRestarted
event is raised when a running process is replaced by a new one, with the reason: an unexpected exit, a change
of the process settings or a recycling rule. Starts and stops, including the stop after the idle timeout, are
not replacements. The event handlers run on the thread pool and cannot delay a conversion.
GlobalSettings.PersistentRendererRestarted += (sender, e) =>
logger.LogWarning("HTML rendering engine replaced: {Reason}, new process {ProcessId}", e.Reason, e.ProcessId);
var status = GlobalSettings.PersistentRendererStatus;
if (status.IsRunning)
logger.LogInformation("Engine process {ProcessId}: {Sent} conversions sent, {InFlight} in progress",
status.ProcessId, status.ConversionsSent, status.ConversionsInFlight);
A web application converts on demand and benefits most from the persistent process. Two calls at the
application lifetime events make the first request as fast as the following ones and stop the engine cleanly
when the application shuts down:
var app = builder.Build();
app.Lifetime.ApplicationStarted.Register(() => GlobalSettings.StartPersistentRenderer());
app.Lifetime.ApplicationStopping.Register(() => GlobalSettings.StopPersistentRenderer());
The asynchronous counterparts StartPersistentRendererAsync(String, CancellationToken)
and StopPersistentRendererAsync(CancellationToken)
are available for hosted services. On Azure App Service enable the Always On setting so that the application
and its engine process are not stopped between requests. On a consumption plan, of Azure Functions for
example, each new instance pays the first start of the engine, which can take a minute on its disk; the
conversions of that instance are fast from the second one on.
The engine process is closed together with the application, so stopping the debugger or rebuilding in Visual
Studio leaves the engine files free. To replace the binaries of an application that stays running, stop the
engine first with StopPersistentRenderer
or let the idle timeout close it.
The figures below were measured with the benchmark application published with the library, on a two page
invoice document, with the default settings. They show the relationship between cores, parallel conversions
and throughput. The absolute numbers depend on the hardware and on the documents.
Machine | Result |
|---|
Windows laptop, 4 physical cores, one conversion at a time | 220 to 300 ms per conversion |
Windows laptop, 4 physical cores, 8 parallel conversions | 7 to 8 conversions per second |
Windows server, 16 physical cores, 16 parallel conversions | 16 to 18 conversions per second, 4,000 conversions without a failure |
Linux virtual machine, 1 physical core, one conversion at a time | About 3 conversions per second, 300 ms per conversion |
macOS, Apple M2, 4 parallel conversions | 7 to 8 conversions per second, 350 ms for one conversion at a time |
Throughput grows almost linearly with the number of parallel conversions up to the number of logical
processors and falls beyond it, while the latency of each conversion grows with every conversion added. Keep
MaxParallelConversions at its default for the highest throughput, or set it to the
number of physical cores for the lowest latency per conversion.
Over long runs the memory of the engine process settles after the first thousand conversions and the
number of handles of the application stays constant. On a 4 core laptop, 2,000 conversions on 4 threads
sampled every 250 conversions show the engine at 0.9 GB after 250, 1.3 GB from 750 on and 1 GB at the end,
with the handle count of the application unchanged and a median of about 860 ms for a conversion with an
HTML header and footer. A run of 4,000 conversions on 16 threads completes without a failure.