Convert HTML to PDF asynchronously and in parallel in C#
ConvertUrlAsync and ConvertHtmlStringAsync hold no thread while a page renders, so you can start many conversions and await them together. Give each conversion its own converter, cap how many render at once, and bound the time with a cancellation token.
Compiled and run: Linux ARM64 · Linux x64 · macOS ARM64 · Windows x64
Build it step by step.
-
1Install the NuGet packages
SelectPdf.Universalis the library. The native package carries the Chromium engine for one platform: add the one for the platform you run on.dotnet add package SelectPdf.Universal dotnet add package SelectPdf.Universal.Native.win-x64
dotnet add package SelectPdf.Universal dotnet add package SelectPdf.Universal.Native.linux-x64
dotnet add package SelectPdf.Universal dotnet add package SelectPdf.Universal.Native.linux-arm64
dotnet add package SelectPdf.Universal dotnet add package SelectPdf.Universal.Native.osx-arm64
-
2Create a console app
Any .NET 8 or .NET 10 project works. To follow along, create a console app, run the install commands above inside its folder, then replace
Program.cswith the code below.dotnet new console -n PdfSample cd PdfSample
-
3Cap how many render at once
// at most 4 pages render at the same time; the rest wait their turn in order // (set it before the first conversion; the default is 8) HtmlToPdfOptions.MaximumConcurrentConversions = 4;
HtmlToPdfOptions.MaximumConcurrentConversionsis the number of pages rendering at the same time; conversions past it wait their turn, in order. It is global, so set it once, before the first conversion. The default is 8. -
4The pages to convert
string[] urls = [ "https://selectpdf.com", "https://selectpdf.com/pricing/", "https://selectpdf.com/pdf-library-cross-platform/", "https://selectpdf.com/html-to-pdf-api/", "https://selectpdf.com/community-edition/", "https://selectpdf.com/downloads/", ];Six pages of selectpdf.com. In your application these are the reports, invoices or pages you need as PDF.
-
5Bound the whole batch
// a time limit for the whole batch using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2));
A
CancellationTokenSourcewith a timeout: if the batch takes longer than two minutes, every conversion still waiting or rendering is cancelled. -
6Start them all, then await them together
// start every conversion, then wait for all of them: one converter per conversion PdfDocument[] docs = await Task.WhenAll(urls.Select(url => { HtmlToPdf converter = new HtmlToPdf(); return converter.ConvertUrlAsync(url, timeout.Token); }));Each URL gets its own
HtmlToPdf, because a converter runs one conversion at a time.ConvertUrlAsyncreturns a task at once;Task.WhenAllwaits for all six. No thread is held while the pages render. -
7Save in order
// the documents come back in the order of the URLs for (int i = 0; i < docs.Length; i++) { docs[i].Save($"page-{i + 1}.pdf"); docs[i].Close(); }Task.WhenAllreturns the documents in the order of the tasks, sodocs[i]belongs tourls[i], whichever finished first.Savewrites each file andClosereleases it. -
8Run it
dotnet run
The program writes
page-1.pdfto the working folder. Without a license key, every page carries a trial watermark; setGlobalProperties.LicenseKeybefore the first conversion to remove it.
The complete Program.cs.
This is the exact file our samples pipeline compiled and ran on Linux ARM64 and Linux x64 and macOS ARM64 and Windows x64 to produce the output on this page.
using SelectPdf.Universal;
// at most 4 pages render at the same time; the rest wait their turn in order
// (set it before the first conversion; the default is 8)
HtmlToPdfOptions.MaximumConcurrentConversions = 4;
string[] urls =
[
"https://selectpdf.com",
"https://selectpdf.com/pricing/",
"https://selectpdf.com/pdf-library-cross-platform/",
"https://selectpdf.com/html-to-pdf-api/",
"https://selectpdf.com/community-edition/",
"https://selectpdf.com/downloads/",
];
// a time limit for the whole batch
using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2));
// start every conversion, then wait for all of them: one converter per conversion
PdfDocument[] docs = await Task.WhenAll(urls.Select(url =>
{
HtmlToPdf converter = new HtmlToPdf();
return converter.ConvertUrlAsync(url, timeout.Token);
}));
// the documents come back in the order of the URLs
for (int i = 0; i < docs.Length; i++)
{
docs[i].Save($"page-{i + 1}.pdf");
docs[i].Close();
}
Open the project on GitHub → this program and the variations below, ready to dotnet run
The PDF it produces.
Download page-1.pdf ↓ the file this sample produced, unedited
Variations you will need next.
Each variation is a separate file in the same project, compiled and run with the program above.
A long batch with Parallel.ForEachAsync
For hundreds of documents, holding every PdfDocument until the end costs memory. Parallel.ForEachAsync with a MaxDegreeOfParallelism converts a few at a time and saves each PDF as soon as it is ready; the token it passes stops the remaining work when the batch is cancelled.
using SelectPdf.Universal;
static class InvoiceBatch
{
public static async Task Run()
{
Directory.CreateDirectory("invoices");
var invoices = Enumerable.Range(1001, 20);
// a long batch: convert at most as many as there are processors at a time,
// and save each PDF as soon as it is ready instead of holding them all in memory
var options = new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount };
await Parallel.ForEachAsync(invoices, options, async (number, cancel) =>
{
string html = $"<h1>Invoice #{number}</h1><p>Billed to customer {number % 7 + 1}.</p>";
HtmlToPdf converter = new HtmlToPdf();
PdfDocument doc = await converter.ConvertHtmlStringAsync(html, cancel);
doc.Save(Path.Combine("invoices", $"invoice-{number}.pdf"));
doc.Close();
});
}
}
Keep going when one page fails
Task.WhenAll fails as soon as one task fails. Catching inside each task turns a failure, or a per-page timeout, into a result, so the other pages are still saved. Here an unknown host fails with "Domain name cannot be resolved" and the two real pages are converted.
using SelectPdf.Universal;
static class KeepGoingOnErrors
{
public static async Task Run()
{
string[] urls = ["https://selectpdf.com", "https://unknown-host.invalid", "https://selectpdf.com/pricing/"];
// one task per page that never throws: a failed or slow page is reported, the rest are saved
var results = await Task.WhenAll(urls.Select(async url =>
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60)); // per page
try
{
HtmlToPdf converter = new HtmlToPdf();
return (url, doc: await converter.ConvertUrlAsync(url, timeout.Token), error: (string?)null);
}
catch (OperationCanceledException)
{
return (url, doc: (PdfDocument?)null, error: "timed out");
}
catch (Exception ex)
{
return (url, doc: (PdfDocument?)null, error: ex.Message);
}
}));
for (int i = 0; i < results.Length; i++)
{
var (url, doc, error) = results[i];
if (doc is null) { Console.WriteLine($"{url}: {error}"); continue; }
doc.Save($"result-{i + 1}.pdf");
doc.Close();
}
}
}
Render parts of one document at the same time
AddAsync starts rendering an HTML element as soon as it is added, so several regions of one document render together, and they are placed in the order they were added. An element positioned from another one's result, like the text below the first region, awaits that element first.
using SelectPdf.Universal;
static class RegionsAtOnce
{
public static async Task Run()
{
// one document, two web pages rendered into it at the same time
PdfDocument doc = new PdfDocument();
PdfPage page1 = doc.AddPage();
PdfPage page2 = doc.AddPage();
Task<PdfRenderingResult> first = page1.AddAsync(new PdfHtmlElement("https://selectpdf.com"));
Task<PdfRenderingResult> second = page2.AddAsync(new PdfHtmlElement("https://selectpdf.com/pricing/"));
// an element placed below the first region waits for that region
PdfRenderingResult result = await first;
PdfFont font = doc.Fonts.Add(PdfStandardFont.Helvetica);
font.Size = 10;
await page1.AddAsync(new PdfTextElement(0, result.PdfPageLastRectangle.Bottom + 10, "Captured with SelectPdf", font));
await second;
doc.Save("two-pages.pdf");
doc.Close();
}
}
Common problems and fixes.
The conversions do not run in parallel
Create a converter for each conversion: one HtmlToPdf object runs one conversion at a time. More than MaximumConcurrentConversions started together wait their turn.
Changing MaximumConcurrentConversions has no effect
It must be set before the first conversion of the application, for example at startup.
Raising the limit does not make the batch faster
Rendering is processor bound: past the number of cores, more pages at once only share them. Measure your own pages before raising the limit.
A cancelled conversion throws
Cancelling makes the task end cancelled, which await raises as OperationCanceledException. Catch it per conversion, as the second variation does, to treat a timeout as a result.
Good to know.
- While a page loads and renders, an asynchronous conversion holds no thread, and it produces the same document as the synchronous one. Source
- One HtmlToPdf object runs one conversion at a time: use a converter for each conversion that runs at the same time. Source
- Up to MaximumConcurrentConversions conversions render at once (8 by default); the rest wait in the order they were started, in the same queue as synchronous conversions. Source
- Cancelling the token stops a conversion wherever it is: a waiting one leaves the queue, a rendering one is stopped, and no document is produced. Source
Questions, answered.
Short answers about this guide. The SelectPdf.Universal documentation covers every class and option.