§HOW-TO GUIDE · C# · .NET 8 & 10

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

Video walkthrough · 3:10 · Watch on YouTube · Short · 0:54
§STEP BY STEP

Build it step by step.

  1. 1Install the NuGet packages

    SelectPdf.Universal is the library. The native package carries the Chromium engine for one platform: add the one for the platform you run on.

    terminal
    dotnet add package SelectPdf.Universal
    dotnet add package SelectPdf.Universal.Native.win-x64
    terminal
    dotnet add package SelectPdf.Universal
    dotnet add package SelectPdf.Universal.Native.linux-x64
    terminal
    dotnet add package SelectPdf.Universal
    dotnet add package SelectPdf.Universal.Native.linux-arm64
    terminal
    dotnet add package SelectPdf.Universal
    dotnet add package SelectPdf.Universal.Native.osx-arm64
  2. 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.cs with the code below.

    terminal
    dotnet new console -n PdfSample
    cd PdfSample
  3. 3Cap how many render at once

    Program.cs · lines 3–5
    // 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.MaximumConcurrentConversions is 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.

  4. 4The pages to convert

    Program.cs · lines 7–15
    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.

  5. 5Bound the whole batch

    Program.cs · lines 17–18
    // a time limit for the whole batch
    using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2));

    A CancellationTokenSource with a timeout: if the batch takes longer than two minutes, every conversion still waiting or rendering is cancelled.

  6. 6Start them all, then await them together

    Program.cs · lines 20–25
    // 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. ConvertUrlAsync returns a task at once; Task.WhenAll waits for all six. No thread is held while the pages render.

  7. 7Save in order

    Program.cs · lines 27–32
    // 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.WhenAll returns the documents in the order of the tasks, so docs[i] belongs to urls[i], whichever finished first. Save writes each file and Close releases it.

  8. 8Run it

    terminal
    dotnet run

    The program writes page-1.pdf to the working folder. Without a license key, every page carries a trial watermark; set GlobalProperties.LicenseKey before the first conversion to remove it.

§COMPLETE PROGRAM

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.

Program.cs
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

§OUTPUT

The PDF it produces.

First page of page-1.pdf, the PDF this sample produces
page-1.pdf, page 1

Download page-1.pdf ↓ the file this sample produced, unedited

§VARIATIONS

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.

InvoiceBatch.cs
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.

KeepGoingOnErrors.cs
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.

RegionsAtOnce.cs
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();
    }
}
§TROUBLESHOOTING

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

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
?FAQ

Questions, answered.

Short answers about this guide. The SelectPdf.Universal documentation covers every class and option.

Create an HtmlToPdf converter per URL, call ConvertUrlAsync on each, and await them together with Task.WhenAll. Up to MaximumConcurrentConversions render at once; the rest queue in order.
It produces the same document. The difference is that no thread is held while the page loads and renders, so a web app or service can have many conversions in flight.
Pass a CancellationToken from a CancellationTokenSource with a timeout to ConvertUrlAsync or ConvertHtmlStringAsync. When it fires, the conversion leaves the queue or stops rendering, and the task ends cancelled.
Up to HtmlToPdfOptions.MaximumConcurrentConversions, 8 by default; zero or a negative value removes the limit. Set it before the first conversion.