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 Two Rendering Modes

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.

C#
// The default: one rendering engine process for the application
GlobalSettings.HtmlRendererMode = HtmlRendererMode.PersistentProcess;

// A rendering engine process for each conversion
GlobalSettings.HtmlRendererMode = HtmlRendererMode.ProcessPerConversion;

Choosing a Mode

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.

  Note

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.

Lifetime of the Persistent Process

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.

Parallel Conversions

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.

  Note

MaxParallelConversions must be set before the first conversion of the application. A change made after the first conversion is ignored.

Monitoring the Persistent Process

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.

C#
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);

Using the Persistent Process in ASP.NET Core

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:

C#
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.

Performance

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.

See Also