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
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 Microsoft.Extensions.Hosting
dotnet add package SelectPdf.Universal dotnet add package SelectPdf.Universal.Native.linux-x64 dotnet add package Microsoft.Extensions.Hosting
dotnet add package SelectPdf.Universal dotnet add package SelectPdf.Universal.Native.linux-arm64 dotnet add package Microsoft.Extensions.Hosting
dotnet add package SelectPdf.Universal dotnet add package SelectPdf.Universal.Native.osx-arm64 dotnet add package Microsoft.Extensions.Hosting
-
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
-
3Register the queue and the worker
// 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.Hostingholds the app's services. TheChannel<PdfJob>is a singleton, so the code that queues jobs and the worker share it;AddHostedServiceregisters the worker, andStartAsyncstarts it. -
4Queue the jobs
// 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
WriteAsynccompletes at once, so the code that queues a job, for example a web request, does not wait for the PDF. -
5Complete the queue and wait for the host
// no more jobs: the worker finishes the queue, then stops the application queue.Writer.Complete(); await host.WaitForShutdownAsync();
Completetells the readers that no more jobs will come.WaitForShutdownAsyncreturns when the application stops: here, when the worker has emptied the queue, or on Ctrl+C. -
6The job
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.
-
7Read the queue in ExecuteAsync
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
BackgroundServiceand gets the channel from dependency injection.ReadAllAsyncreturns each job as it arrives and ends when the channel is completed and empty. When the host stops,stoppingTokenis cancelled and the loop ends with anOperationCanceledException. -
8Convert each job
// 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.ConvertUrlAsyncorConvertHtmlStringAsyncgets the stopping token, so a conversion still running when the host stops is cancelled.Savewrites the file andClosereleases the document. -
9Stop when the queue is done
// 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
StopApplicationonce the queue is empty, anddotnet runends. A long-running service leaves the channel open and keeps reading until the host stops. -
10Run it
dotnet run
The program writes
pricing.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 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
The PDF it produces.
Download pricing.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.
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.
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.
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.
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;
}
}
}
}
}
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.
- 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
Questions, answered.
Short answers about this guide. The SelectPdf.Universal documentation covers every class and option.