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

Generate PDFs in a background queue in .NET (BackgroundService)

Write PDF jobs to a Channel and let a BackgroundService convert them with ConvertHtmlStringAsync and ConvertUrlAsync. The code that queues a job returns at once, each job gets its own converter, and the host's stopping token cancels a conversion still running at shutdown.

Compiled and run: Linux ARM64 · Linux x64 · macOS ARM64 · Windows x64

Video walkthrough · 3:52 · Watch on YouTube · Short · 0:53
§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
    dotnet add package Microsoft.Extensions.Hosting
    terminal
    dotnet add package SelectPdf.Universal
    dotnet add package SelectPdf.Universal.Native.linux-x64
    dotnet add package Microsoft.Extensions.Hosting
    terminal
    dotnet add package SelectPdf.Universal
    dotnet add package SelectPdf.Universal.Native.linux-arm64
    dotnet add package Microsoft.Extensions.Hosting
    terminal
    dotnet add package SelectPdf.Universal
    dotnet add package SelectPdf.Universal.Native.osx-arm64
    dotnet add package Microsoft.Extensions.Hosting
  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. 3Register the queue and the worker

    Program.cs · lines 6–11
    // the queue is a singleton: the app writes jobs, the background worker reads them
    var builder = Host.CreateApplicationBuilder(args);
    builder.Services.AddSingleton(Channel.CreateUnbounded<PdfJob>());
    builder.Services.AddHostedService<PdfWorker>();
    using IHost host = builder.Build();
    await host.StartAsync();

    The generic host from Microsoft.Extensions.Hosting holds the app's services. The Channel<PdfJob> is a singleton, so the code that queues jobs and the worker share it; AddHostedService registers the worker, and StartAsync starts it.

  4. 4Queue the jobs

    Program.cs · lines 13–17
    // queue a few jobs: writing returns at once, the worker converts them in the background
    var queue = host.Services.GetRequiredService<Channel<PdfJob>>();
    await queue.Writer.WriteAsync(new PdfJob("invoice-1001.pdf", Html: "<h1>Invoice #1001</h1><p>Annual license</p>"));
    await queue.Writer.WriteAsync(new PdfJob("pricing.pdf", Url: "https://selectpdf.com/pricing/"));
    await queue.Writer.WriteAsync(new PdfJob("invoice-1002.pdf", Html: "<h1>Invoice #1002</h1><p>Priority support</p>"));

    Take the channel from the services and write the jobs: two HTML strings and one URL. On an unbounded channel WriteAsync completes at once, so the code that queues a job, for example a web request, does not wait for the PDF.

  5. 5Complete the queue and wait for the host

    Program.cs · lines 19–21
    // no more jobs: the worker finishes the queue, then stops the application
    queue.Writer.Complete();
    await host.WaitForShutdownAsync();

    Complete tells the readers that no more jobs will come. WaitForShutdownAsync returns when the application stops: here, when the worker has emptied the queue, or on Ctrl+C.

  6. 6The job

    Program.cs · lines 23–23
    record PdfJob(string File, string? Html = null, string? Url = null);

    A record with the file to write and either the HTML or the URL to convert. Keep jobs small: data, not converters or documents.

  7. 7Read the queue in ExecuteAsync

    Program.cs · lines 25–31
    class PdfWorker(Channel<PdfJob> queue, IHostApplicationLifetime lifetime) : BackgroundService
    {
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            // one job at a time, until the queue is completed and empty, or the host stops
            await foreach (PdfJob job in queue.Reader.ReadAllAsync(stoppingToken))
            {

    The worker derives from BackgroundService and gets the channel from dependency injection. ReadAllAsync returns each job as it arrives and ends when the channel is completed and empty. When the host stops, stoppingToken is cancelled and the loop ends with an OperationCanceledException.

  8. 8Convert each job

    Program.cs · lines 32–40
                // a converter per job; the stopping token cancels a conversion still running
                HtmlToPdf converter = new HtmlToPdf();
                PdfDocument doc = job.Url is not null
                    ? await converter.ConvertUrlAsync(job.Url, stoppingToken)
                    : await converter.ConvertHtmlStringAsync(job.Html!, stoppingToken);
                doc.Save(job.File);
                doc.Close();
                Console.WriteLine($"saved {job.File}");
            }

    Each job gets its own HtmlToPdf, because a converter runs one conversion at a time. ConvertUrlAsync or ConvertHtmlStringAsync gets the stopping token, so a conversion still running when the host stops is cancelled. Save writes the file and Close releases the document.

  9. 9Stop when the queue is done

    Program.cs · lines 42–45
            // in this sample the queue is completed once all jobs are in, so stop the application
            lifetime.StopApplication();
        }
    }

    This sample is a short run, so the worker calls StopApplication once the queue is empty, and dotnet run ends. A long-running service leaves the channel open and keeps reading until the host stops.

  10. 10Run it

    terminal
    dotnet run

    The program writes pricing.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 System.Threading.Channels;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using SelectPdf.Universal;

// the queue is a singleton: the app writes jobs, the background worker reads them
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton(Channel.CreateUnbounded<PdfJob>());
builder.Services.AddHostedService<PdfWorker>();
using IHost host = builder.Build();
await host.StartAsync();

// queue a few jobs: writing returns at once, the worker converts them in the background
var queue = host.Services.GetRequiredService<Channel<PdfJob>>();
await queue.Writer.WriteAsync(new PdfJob("invoice-1001.pdf", Html: "<h1>Invoice #1001</h1><p>Annual license</p>"));
await queue.Writer.WriteAsync(new PdfJob("pricing.pdf", Url: "https://selectpdf.com/pricing/"));
await queue.Writer.WriteAsync(new PdfJob("invoice-1002.pdf", Html: "<h1>Invoice #1002</h1><p>Priority support</p>"));

// no more jobs: the worker finishes the queue, then stops the application
queue.Writer.Complete();
await host.WaitForShutdownAsync();

record PdfJob(string File, string? Html = null, string? Url = null);

class PdfWorker(Channel<PdfJob> queue, IHostApplicationLifetime lifetime) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // one job at a time, until the queue is completed and empty, or the host stops
        await foreach (PdfJob job in queue.Reader.ReadAllAsync(stoppingToken))
        {
            // a converter per job; the stopping token cancels a conversion still running
            HtmlToPdf converter = new HtmlToPdf();
            PdfDocument doc = job.Url is not null
                ? await converter.ConvertUrlAsync(job.Url, stoppingToken)
                : await converter.ConvertHtmlStringAsync(job.Html!, stoppingToken);
            doc.Save(job.File);
            doc.Close();
            Console.WriteLine($"saved {job.File}");
        }

        // in this sample the queue is completed once all jobs are in, so stop the application
        lifetime.StopApplication();
    }
}

Open the project on GitHub → this program and the variations below, ready to dotnet run

§OUTPUT

The PDF it produces.

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

Download pricing.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.

Several workers on one queue

Start more than one reader on the same channel and each job is converted by one of them, so a few jobs run at the same time. A bounded channel caps the jobs waiting in the queue: when it is full, WriteAsync waits until a worker takes one. In a BackgroundService, start the readers inside ExecuteAsync and await them together.

SeveralWorkers.cs
using System.Threading.Channels;
using SelectPdf.Universal;

static class SeveralWorkers
{
    public static async Task Run()
    {
        Directory.CreateDirectory("reports");

        // a bounded queue: when it holds 10 jobs, WriteAsync waits until a worker takes one
        var queue = Channel.CreateBounded<int>(10);

        // three workers read the same queue; each job goes to one of them
        Task[] workers = Enumerable.Range(1, 3).Select(worker => Task.Run(async () =>
        {
            await foreach (int number in queue.Reader.ReadAllAsync())
            {
                HtmlToPdf converter = new HtmlToPdf();
                PdfDocument doc = await converter.ConvertHtmlStringAsync(
                    $"<h1>Report {number}</h1><p>Converted by worker {worker}.</p>");
                doc.Save(Path.Combine("reports", $"report-{number}.pdf"));
                doc.Close();
            }
        })).ToArray();

        for (int number = 1; number <= 12; number++)
            await queue.Writer.WriteAsync(number);

        queue.Writer.Complete();
        await Task.WhenAll(workers);
    }
}

Let the caller await its own job

A job can carry a TaskCompletionSource: the worker sets the file name when the PDF is saved, or the exception when the conversion fails, and the code that queued the job awaits Done.Task. Create it with RunContinuationsAsynchronously, so the caller's code does not run on the worker's loop.

AwaitTheResult.cs
using System.Threading.Channels;
using SelectPdf.Universal;

record StatementJob(string Html, string File, TaskCompletionSource<string> Done);

static class AwaitTheResult
{
    public static async Task Run()
    {
        var queue = Channel.CreateUnbounded<StatementJob>();

        // the worker completes each job's TaskCompletionSource with the file, or with the error
        Task worker = Task.Run(async () =>
        {
            await foreach (StatementJob job in queue.Reader.ReadAllAsync())
            {
                try
                {
                    HtmlToPdf converter = new HtmlToPdf();
                    PdfDocument doc = await converter.ConvertHtmlStringAsync(job.Html);
                    doc.Save(job.File);
                    doc.Close();
                    job.Done.SetResult(Path.GetFullPath(job.File));
                }
                catch (Exception ex)
                {
                    job.Done.SetException(ex);
                }
            }
        });

        // the caller queues a job and awaits its own result
        var job = new StatementJob("<h1>Statement for October</h1><p>Balance: 0.00</p>", "statement.pdf",
            new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously));
        await queue.Writer.WriteAsync(job);
        string file = await job.Done.Task;
        Console.WriteLine($"ready: {file}");

        queue.Writer.Complete();
        await worker;
    }
}

Retry a failed job, then move on

Catch inside the loop, so one bad job does not end the worker. Each attempt gets its own time limit and the job is tried up to three times, with a short pause between attempts. Here an unknown host fails with "Domain name cannot be resolved" three times and is reported, and the other job is saved.

RetryFailedJobs.cs
using System.Threading.Channels;
using SelectPdf.Universal;

static class RetryFailedJobs
{
    public static async Task Run()
    {
        var queue = Channel.CreateUnbounded<(string Url, string File)>();
        queue.Writer.TryWrite(("https://selectpdf.com/downloads/", "downloads.pdf"));
        queue.Writer.TryWrite(("https://unknown-host.invalid", "unknown.pdf"));
        queue.Writer.Complete();

        await foreach (var (url, file) in queue.Reader.ReadAllAsync())
        {
            // up to 3 attempts per job, each with its own time limit; a failed job does not stop the worker
            for (int attempt = 1; ; attempt++)
            {
                using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
                try
                {
                    HtmlToPdf converter = new HtmlToPdf();
                    PdfDocument doc = await converter.ConvertUrlAsync(url, timeout.Token);
                    doc.Save(file);
                    doc.Close();
                    Console.WriteLine($"{url}: saved {file}");
                    break;
                }
                catch (Exception ex) when (attempt < 3)
                {
                    Console.WriteLine($"{url}: attempt {attempt} failed ({ex.Message}), retrying");
                    await Task.Delay(TimeSpan.FromSeconds(attempt));
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"{url}: failed after {attempt} attempts ({ex.Message})");
                    break;
                }
            }
        }
    }
}
§TROUBLESHOOTING

Common problems and fixes.

dotnet run does not exit

The host runs until something stops it. Call Complete on the writer when no more jobs will come and StopApplication when the worker is done, as here, or stop it with Ctrl+C.

One failed job stops the whole application

An exception that leaves ExecuteAsync ends the worker, and by default the host then stops. Catch errors per job inside the loop, as the third variation does, and log or retry them.

The jobs run one after the other

One await foreach reads one job at a time. To convert several at once, start a few readers on the same channel, as the first variation does, each with its own converter.

Memory grows when jobs arrive faster than they are converted

An unbounded channel keeps every job written to it. Use Channel.CreateBounded with a capacity, so WriteAsync waits for room when the queue is full.

§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
  • Cancelling the token stops a conversion wherever it is: one waiting for its turn stops waiting, a rendering one is stopped, and no document is produced. Source
  • BackgroundService is an implementation of IHostedService; a service that calls IHostApplicationLifetime.StopApplication() signals the host to stop. Source
  • When a bounded channel is full, WriteAsync waits for space by default. Source
?FAQ

Questions, answered.

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

Write the jobs to a Channel registered as a singleton and read them in a BackgroundService. For each job, create an HtmlToPdf converter, call ConvertHtmlStringAsync or ConvertUrlAsync, then save and close the document.
Pass the stoppingToken of ExecuteAsync to the conversion. When the host stops, the token is cancelled: a conversion still waiting for its turn stops waiting, a rendering one is stopped, and no document is produced.
Yes. Start several readers on the same channel, each with its own HtmlToPdf converter, because one converter runs one conversion at a time. Each job is read by one of them.
Give the job a TaskCompletionSource. The worker sets the result when the PDF is saved, or the exception when it fails, and the caller awaits the task.