Skip to content

Async Node.js Compilation

A warmed up template produces a PDF in single digit milliseconds, but compilation and export are CPU-bound work. The synchronous methods (export, compile, and their format variants) run that work on the main thread and block the Node.js event loop until the document is ready. Under heavy load, or for a single slow document, the blocked event loop stalls every other request in the meantime.

Every compilation and export method has an Async counterpart that runs the work on a background thread from Node.js’ libuv thread pool and returns a Promise. The event loop stays free while the document is generated, so your service keeps serving other requests:

const jsonInputs = new Map<string, string>();
jsonInputs.set('info', JSON.stringify({ name: 'Baby Yoda' }));
const pdf = await template.exportPdfAsync(jsonInputs, new Map());

The async variants take the same parameters as their synchronous counterparts; only the return type changes to a Promise of the bytes (or, for compileAsync, of the compiled document):

  • Template.exportAsync / exportPdfAsync / exportPngAsync / exportSvgAsync compile and export in a single call, then free the document.
  • Template.compileAsync returns a CompiledDocument you can export more than once.
  • CompiledDocument.exportAsync / exportPdfAsync / exportPngAsync / exportSvgAsync export an already compiled document.

Compile once and export several times when you need multiple formats or page ranges from the same inputs:

using document = await template.compileAsync(jsonInputs, new Map());
const pdf = await document.exportPdfAsync();
const thumbnail = await document.exportPngAsync(2.0);

The libuv thread pool defaults to four threads, shared with Node.js’ own file system, DNS, and crypto work. Each in-flight async compilation or export occupies one thread for its duration.

If you generate many documents concurrently, raise the pool size with the UV_THREADPOOL_SIZE environment variable:

Terminal window
UV_THREADPOOL_SIZE=8 node dist/main.js

Because the work is CPU-bound, there is little benefit to sizing the pool much beyond the number of CPU cores available to your service. Extra threads just compete for the same cores. Start at the core count and measure under your real load.

Async calls are safe to run concurrently. Independent compilations and exports proceed in parallel across the pool, up to the thread count, and share the compilation cache. Fire several off with Promise.all when a request needs more than one document:

const [invoice, receipt] = await Promise.all([
invoiceTemplate.exportPdfAsync(invoiceInputs, new Map()),
receiptTemplate.exportPdfAsync(receiptInputs, new Map()),
]);

With the async API you rarely need worker threads. If you want to isolate compilation in a separate thread pool, or keep it off the pool your other libuv work depends on, a library like piscina can help