← All guides

How to convert HTML to PDF in C# and .NET

.NET has several commercial HTML-to-PDF libraries, but the free options that render modern CSS correctly all come down to driving headless Chromium. This guide shows how to do that with PuppeteerSharp and Playwright for .NET, what it takes to run in ASP.NET Core, and then how to call PDFOven with HttpClient instead.

Option 1: PuppeteerSharp

PuppeteerSharp is a .NET port of Puppeteer. It downloads a Chrome build and controls it over the DevTools protocol.

dotnet add package PuppeteerSharp
using PuppeteerSharp;
using PuppeteerSharp.Media;

var fetcher = new BrowserFetcher();
await fetcher.DownloadAsync();

await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions { Headless = true });
await using var page = await browser.NewPageAsync();

await page.SetContentAsync(html, new NavigationOptions
{
    WaitUntil = new[] { WaitUntilNavigation.Networkidle0 }
});

byte[] pdf = await page.PdfDataAsync(new PdfOptions
{
    Format = PaperFormat.A4,
    PrintBackground = true,
    MarginOptions = new MarginOptions { Top = "12mm", Bottom = "12mm", Left = "12mm", Right = "12mm" }
});

await File.WriteAllBytesAsync("invoice.pdf", pdf);

BrowserFetcher.DownloadAsync fetches Chrome on first run, a download of a few hundred megabytes. In production, do this at build time rather than on the first request.

Option 2: Playwright for .NET

Playwright's .NET package works the same way with a slightly different API:

dotnet add package Microsoft.Playwright
dotnet build
pwsh bin/Debug/net8.0/playwright.ps1 install chromium
using Microsoft.Playwright;

using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync();
var page = await browser.NewPageAsync();

await page.SetContentAsync(html, new PageSetContentOptions { WaitUntil = WaitUntilState.NetworkIdle });

byte[] pdf = await page.PdfAsync(new PagePdfOptions
{
    Format = "A4",
    PrintBackground = true,
    Margin = new Margin { Top = "12mm", Bottom = "12mm", Left = "12mm", Right = "12mm" }
});

Both libraries give the same output, because both use Chromium's print pipeline. Pick whichever your team already uses for browser testing.

Running it in ASP.NET Core

Launching a browser per request is slow and memory hungry. Register one browser as a singleton and open a page per request:

builder.Services.AddSingleton(_ =>
    Puppeteer.LaunchAsync(new LaunchOptions { Headless = true }).GetAwaiter().GetResult());

app.MapGet("/invoices/{id:int}/pdf", async (int id, IBrowser browser, InvoiceRenderer renderer) =>
{
    string html = await renderer.RenderHtmlAsync(id);
    await using var page = await browser.NewPageAsync();
    await page.SetContentAsync(html);
    byte[] pdf = await page.PdfDataAsync(new PdfOptions { Format = PaperFormat.A4, PrintBackground = true });
    return Results.File(pdf, "application/pdf", $"invoice-{id}.pdf");
});

InvoiceRenderer stands in for however you produce HTML, such as a Razor view rendered to a string.

What to know:

  • Deployment size and dependencies. Chromium needs a long list of native libraries on Linux. The official .NET runtime images do not include them, so you add them to your Dockerfile. Azure App Service on Windows and some PaaS sandboxes restrict launching child processes, which can block Chromium entirely.
  • Memory. Each page uses tens to hundreds of megabytes. Without a concurrency limit, such as a SemaphoreSlim around rendering, a burst of requests can exhaust the container.
  • Crash recovery. A singleton browser that crashes takes every subsequent request down with it. You need health checks and logic to relaunch.
  • Fonts. Fonts must be installed in the image, or Chromium silently substitutes another face.
  • Untrusted HTML. Chromium will fetch any URL in the document. If users influence the HTML, block internal network access.

The PDFOven way: HttpClient

PDFOven runs Chromium for you. Your ASP.NET Core app sends JSON and receives PDF bytes, with no browser in your image.

Register a typed client:

builder.Services.AddHttpClient<PdfOvenClient>(client =>
{
    client.BaseAddress = new Uri("https://pdfoven.com");
    client.Timeout = TimeSpan.FromSeconds(60);
    client.DefaultRequestHeaders.Authorization =
        new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["PdfOven:Key"]);
});
using System.Net.Http.Json;

public record PdfOvenError(string Code, string Message);
public record PdfOvenErrorEnvelope(PdfOvenError Error);

public class PdfOvenClient(HttpClient http)
{
    public async Task<byte[]> RenderPdfAsync(string html, CancellationToken ct = default)
    {
        var body = new
        {
            html,
            options = new
            {
                format = "A4",
                margin = "0.5in",
                print_background = true,
                footer_template = "<div style=\"font-size:9px;width:100%;text-align:center\">Page <span class=\"pageNumber\"></span> of <span class=\"totalPages\"></span></div>"
            }
        };

        using var response = await http.PostAsJsonAsync("/v1/pdf", body, ct);

        if (!response.IsSuccessStatusCode)
        {
            var envelope = await response.Content.ReadFromJsonAsync<PdfOvenErrorEnvelope>(ct);
            throw new HttpRequestException(
                $"PDF render failed: {envelope?.Error.Code}: {envelope?.Error.Message}", null, response.StatusCode);
        }

        return await response.Content.ReadAsByteArrayAsync(ct);
    }
}

The anonymous object's property names are already snake_case, which is what the API expects. ReadFromJsonAsync uses case-insensitive matching by default, so the records bind to { "error": { "code", "message" } }.

The endpoint then becomes:

app.MapGet("/invoices/{id:int}/pdf", async (int id, InvoiceRenderer renderer, PdfOvenClient pdfOven, CancellationToken ct) =>
{
    string html = await renderer.RenderHtmlAsync(id);
    byte[] pdf = await pdfOven.RenderPdfAsync(html, ct);
    return Results.File(pdf, "application/pdf", $"invoice-{id}.pdf");
});

Passing a file name to Results.File sets Content-Disposition: attachment, so the browser downloads the file.

Other inputs and options

Send exactly one of html, markdown, url, template with data, or template_id with data. Add wait_for_selector for pages that render charts with JavaScript, css to send styles separately, and response = "base64" to receive {"data", "content_type", "bytes"} as JSON. Posting the same body to /v1/image returns a PNG, or JPEG with ?type=jpeg.

Asset URLs in your HTML must be absolute and publicly reachable, since rendering happens on PDFOven's servers rather than next to your app.

Comparison

PuppeteerSharp / Playwright PDFOven
Rendering engine Chromium Chromium
Native dependencies Chromium and its libraries, fonts None
Browser lifecycle You manage Managed
Works on restricted PaaS Often not Yes
Works offline Yes No

PDFOven's free plan includes 100 renders per month; paid plans are $12, $39 and $99 for 2,000, 15,000 and 60,000 renders. A 429 with code quota_exceeded means the monthly quota is used up, so do not retry it in a Polly policy. Generated files are not stored.

Skip the browser plumbing

PDFOven runs Chromium for you. Send HTML, Markdown, a URL or JSON to a template and get a PDF back. 100 free renders every month.

Get a free API key