HTML to PDF in C# - The Reality of Library Options
Converting HTML to PDF in C# requires a library that actually renders HTML — not one that parses a subset of tags and approximates CSS 2.1. Most libraries recommended in Stack Overflow threads and Reddit discussions either can't render modern CSS, carry licensing restrictions that disqualify them for commercial use, or have been abandoned with unpatched security vulnerabilities.
This article compares the libraries that developers actually encounter when searching for "HTML to PDF C#," documents what each can and cannot render, includes performance benchmarks with methodology, and shows the real operational cost of each approach.
Quickstart: HTML to PDF in C#
using IronPdf;
var renderer = new ChromePdfRenderer();
var pdf = renderer.RenderHtmlAsPdf("<h1>Hello World</h1>");
pdf.SaveAs("output.pdf");using IronPdf;
var renderer = new ChromePdfRenderer();
var pdf = renderer.RenderHtmlAsPdf("<h1>Hello World</h1>");
pdf.SaveAs("output.pdf");Imports IronPdf
Dim renderer As New ChromePdfRenderer()
Dim pdf = renderer.RenderHtmlAsPdf("<h1>Hello World</h1>")
pdf.SaveAs("output.pdf")Install via NuGet: Install-Package IronPDF. Deploys to Windows, Linux, macOS, and Docker without external dependencies.
Why Is HTML-to-PDF Conversion Difficult?
Rendering HTML to PDF correctly requires implementing the same five components a web browser uses: an HTML parser, a CSS engine (including Flexbox, Grid, cascade, specificity, and media queries), a JavaScript runtime, a layout engine, and a rendering pipeline that composites all of this to PDF with sub-pixel accuracy.
Traditional PDF libraries implement the first two partially and skip JavaScript entirely. This is why they handle simple HTML but break on anything a modern browser renders correctly. The only way to match browser output is to use a browser engine.
Which Libraries Actually Convert HTML to PDF?
wkhtmltopdf Wrappers — The DLL Loading Error Ecosystem
The most common search query bringing developers to these articles is some variation of:
System.DllNotFoundException: Unable to load DLL 'libwkhtmltox'Platform-specific variants include:
Unable to load shared library 'wkhtmltox' or one of its dependencies
(Linux — libwkhtmltox.so not found)
The specified module could not be found. (0x8007007E)
(Windows — wkhtmltox.dll path not configured)
dyld: Library not loaded: libwkhtmltox.dylib
(macOS — not supported on ARM64/Apple Silicon)These errors come from DinkToPdf, NReco.PdfGenerator, WkHtmlToXSharp, and other C# wrappers around the same abandoned binary. The wkhtmltopdf GitHub organization was archived in July 2024. The underlying QtWebKit engine was deprecated by Qt in 2015. The project status page explicitly marks it as deprecated.
Beyond the DLL loading issues, the rendering engine is frozen at approximately Safari 2011 capability. No Flexbox, no Grid, limited CSS3, unreliable JavaScript. And there are unpatched critical vulnerabilities: CVE-2022-35583(CVSS 9.8) enables SSRF attacks that can exfiltrate AWS credentials through crafted HTML.
wkhtmltopdf's time has passed. The DLL loading errors are a symptom of a deeper problem: you're depending on abandoned software with no path forward.
iText 7 (pdfHTML Add-On) — Limited CSS, AGPL Licensed
iText's pdfHTML module converts HTML to PDF using a custom parser — not a browser engine. It handles basic HTML/CSS but doesn't render Flexbox, Grid, or JavaScript.
The failure mode is silent: pdfHTML doesn't throw exceptions when it encounters unsupported CSS. It renders what it can and ignores the rest. A display: flex container with gap: 20px and justify-content: space-between renders as vertically stacked elements with no spacing. Developers discover this after integration, not during.
Licensing: AGPL — requires open-sourcing your entire network-accessible application, or purchasing commercial licensing. Pricing is not published; third-party data suggests $15,000–$210,000 annually.
How Does Memory Usage Compare?
iText's pdfHTML loads the entire document into memory for processing. For typical business documents this is manageable, but large HTML reports with embedded images can cause significant memory pressure compared to streaming approaches.
Why Doesn't PdfSharp Support HTML?
PdfSharp appears in "HTML to PDF" search results because of its popularity (34.9 million NuGet downloads) and frequent recommendations. But PdfSharp has no HTML parser. It provides a coordinate-based drawing API: DrawString(), DrawRectangle(), DrawImage() with explicit X/Y positions.
The commonly suggested workaround, HtmlRenderer.PdfSharp, supports HTML 4.01 and CSS Level 2 only. If your HTML uses any CSS feature introduced after 2010 — Flexbox (2012), Grid (2017), custom properties (2017), border-radius (2011) — it won't render.
Developers who select PdfSharp expecting HTML support end up either manually positioning every element with coordinate-based code or adding a second library for HTML rendering — at which point PdfSharp is redundant.
What Makes Puppeteer Sharp Resource-Intensive?
Puppeteer Sharp controls headless Chrome via .NET bindings. Rendering accuracy matches Chrome because it is Chrome. The cost is operational: you manage external browser processes.
Here's what production Puppeteer Sharp deployment actually looks like — not the 5-line example from tutorials, but the browser pooling code you need for concurrent PDF generation:
using PuppeteerSharp;
using System.Collections.Concurrent;
public class PdfBrowserPool : IAsyncDisposable
{
private readonly ConcurrentBag<IBrowser> _available = new();
private readonly SemaphoreSlim _semaphore;
private readonly int _maxBrowsers;
public PdfBrowserPool(int maxBrowsers = 4)
{
_maxBrowsers = maxBrowsers;
_semaphore = new SemaphoreSlim(maxBrowsers, maxBrowsers);
}
public async Task InitializeAsync()
{
await new BrowserFetcher().DownloadAsync(); // ~280MB download
for (int i = 0; i < _maxBrowsers; i++)
{
var browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true,
Args = new[] { "--no-sandbox", "--disable-setuid-sandbox",
"--disable-dev-shm-usage" }
});
_available.Add(browser);
}
}
public async Task<byte[]> ConvertHtmlToPdf(string html)
{
await _semaphore.WaitAsync();
IBrowser browser = null;
try
{
if (!_available.TryTake(out browser))
throw new InvalidOperationException("No browser available");
await using var page = await browser.NewPageAsync();
await page.SetContentAsync(html, new NavigationOptions
{
WaitUntil = new[] { WaitUntilNavigation.Networkidle0 }
});
var result = await page.PdfAsync(new PdfOptions
{
Format = PaperFormat.A4,
PrintBackground = true
});
return result;
}
catch (Exception ex) when (ex is NavigationException or TargetClosedException)
{
// Browser crashed — replace it
browser?.Dispose();
browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true,
Args = new[] { "--no-sandbox", "--disable-setuid-sandbox" }
});
throw; // Re-throw after recovery
}
finally
{
if (browser != null) _available.Add(browser);
_semaphore.Release();
}
}
public async ValueTask DisposeAsync()
{
foreach (var browser in _available)
{
await browser.CloseAsync();
browser.Dispose();
}
}
}using PuppeteerSharp;
using System.Collections.Concurrent;
public class PdfBrowserPool : IAsyncDisposable
{
private readonly ConcurrentBag<IBrowser> _available = new();
private readonly SemaphoreSlim _semaphore;
private readonly int _maxBrowsers;
public PdfBrowserPool(int maxBrowsers = 4)
{
_maxBrowsers = maxBrowsers;
_semaphore = new SemaphoreSlim(maxBrowsers, maxBrowsers);
}
public async Task InitializeAsync()
{
await new BrowserFetcher().DownloadAsync(); // ~280MB download
for (int i = 0; i < _maxBrowsers; i++)
{
var browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true,
Args = new[] { "--no-sandbox", "--disable-setuid-sandbox",
"--disable-dev-shm-usage" }
});
_available.Add(browser);
}
}
public async Task<byte[]> ConvertHtmlToPdf(string html)
{
await _semaphore.WaitAsync();
IBrowser browser = null;
try
{
if (!_available.TryTake(out browser))
throw new InvalidOperationException("No browser available");
await using var page = await browser.NewPageAsync();
await page.SetContentAsync(html, new NavigationOptions
{
WaitUntil = new[] { WaitUntilNavigation.Networkidle0 }
});
var result = await page.PdfAsync(new PdfOptions
{
Format = PaperFormat.A4,
PrintBackground = true
});
return result;
}
catch (Exception ex) when (ex is NavigationException or TargetClosedException)
{
// Browser crashed — replace it
browser?.Dispose();
browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true,
Args = new[] { "--no-sandbox", "--disable-setuid-sandbox" }
});
throw; // Re-throw after recovery
}
finally
{
if (browser != null) _available.Add(browser);
_semaphore.Release();
}
}
public async ValueTask DisposeAsync()
{
foreach (var browser in _available)
{
await browser.CloseAsync();
browser.Dispose();
}
}
}Imports PuppeteerSharp
Imports System.Collections.Concurrent
Imports System.Threading
Public Class PdfBrowserPool
Implements IAsyncDisposable
Private ReadOnly _available As New ConcurrentBag(Of IBrowser)()
Private ReadOnly _semaphore As SemaphoreSlim
Private ReadOnly _maxBrowsers As Integer
Public Sub New(Optional maxBrowsers As Integer = 4)
_maxBrowsers = maxBrowsers
_semaphore = New SemaphoreSlim(maxBrowsers, maxBrowsers)
End Sub
Public Async Function InitializeAsync() As Task
Await (New BrowserFetcher()).DownloadAsync() ' ~280MB download
For i As Integer = 0 To _maxBrowsers - 1
Dim browser = Await Puppeteer.LaunchAsync(New LaunchOptions With {
.Headless = True,
.Args = New String() {"--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"}
})
_available.Add(browser)
Next
End Function
Public Async Function ConvertHtmlToPdf(html As String) As Task(Of Byte())
Await _semaphore.WaitAsync()
Dim browser As IBrowser = Nothing
Try
If Not _available.TryTake(browser) Then
Throw New InvalidOperationException("No browser available")
End If
Await Using page = Await browser.NewPageAsync()
Await page.SetContentAsync(html, New NavigationOptions With {
.WaitUntil = New WaitUntilNavigation() {WaitUntilNavigation.Networkidle0}
})
Dim result = Await page.PdfAsync(New PdfOptions With {
.Format = PaperFormat.A4,
.PrintBackground = True
})
Return result
End Using
Catch ex As Exception When TypeOf ex Is NavigationException OrElse TypeOf ex Is TargetClosedException
' Browser crashed — replace it
browser?.Dispose()
browser = Await Puppeteer.LaunchAsync(New LaunchOptions With {
.Headless = True,
.Args = New String() {"--no-sandbox", "--disable-setuid-sandbox"}
})
Throw ' Re-throw after recovery
Finally
If browser IsNot Nothing Then _available.Add(browser)
_semaphore.Release()
End Try
End Function
Public Async Function DisposeAsync() As ValueTask Implements IAsyncDisposable.DisposeAsync
For Each browser In _available
Await browser.CloseAsync()
browser.Dispose()
Next
End Function
End ClassThis is ~60 lines of infrastructure code before you generate a single PDF. You also need memory leak monitoring (Chromium processes accumulate memory over time), health checks, and a Dockerfile with 20+ Chromium dependencies. Docker image size increases by 300–400MB.
Compare this to the equivalent IronPDF approach:
using IronPdf;
var renderer = new ChromePdfRenderer();
var pdf = renderer.RenderHtmlAsPdf(html);
// Browser pooling, process management, crash recovery — handled internallyusing IronPdf;
var renderer = new ChromePdfRenderer();
var pdf = renderer.RenderHtmlAsPdf(html);
// Browser pooling, process management, crash recovery — handled internallyImports IronPdf
Dim renderer As New ChromePdfRenderer()
Dim pdf = renderer.RenderHtmlAsPdf(html)
' Browser pooling, process management, crash recovery — handled internallyPuppeteer Sharp is viable if your team can absorb the operational overhead. For teams that want to focus on their application rather than browser infrastructure, IronPDF handles the same rendering internally.
Why Can't QuestPDF Convert HTML?
QuestPDF appears in virtually every "HTML to PDF C#" discussion on Reddit and Stack Overflow. This creates a consistent pattern: developers purchase or integrate QuestPDF expecting HTML conversion, then discover it doesn't render HTML at all.
QuestPDF is a fluent C# API for programmatic document creation. Its positioning is explicitly "stop fighting with HTML-to-PDF conversion" — it replaces the HTML approach with C# code. This is a deliberate design choice. GitHub discussions from 2022 through 2024 show developers discovering this after beginning implementation. The maintainers consistently confirm HTML support is not planned.
If your existing workflow uses HTML templates — Razor views for invoices, dashboard HTML for reports, web content for archiving — QuestPDF requires rewriting every template in C# fluent API code. For new projects where you're building document layouts from scratch with structured data, QuestPDF's API is well-designed and productive.
The Community License covers businesses under $1M annual gross revenue. Above that, commercial licensing is required.
What About Aspose.PDF?
Aspose.PDF provides broad PDF functionality with commercial licensing (starting ~$999/developer). The HTML conversion uses a custom engine, not a browser — similar to iText, it handles basic HTML but doesn't render modern CSS features accurately.
The primary concern is platform stability: Aspose depends on System.Drawing.Common, which requires libgdiplus on Linux. Microsoft deprecated this for non-Windows platforms in .NET 6+. Developers report memory leaks specific to Linux deployments that don't occur on Windows. For Windows-only environments, Aspose is capable. For cross-platform or containerized deployments, the dependency chain creates ongoing risk.
How Does IronPDF Handle HTML-to-PDF Conversion?
IronPDF embeds Chromium directly in the NuGet package. CSS Flexbox, Grid, custom properties, @font-face, media queries, and JavaScript all execute as they do in Chrome. The output matches the browser because it uses the same rendering engine.
using IronPdf;
var renderer = new ChromePdfRenderer();
string html = @"
<!DOCTYPE html>
<html>
<head>
<style>
:root { --primary: #2563eb; }
body { font-family: 'Segoe UI', sans-serif; padding: 40px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 20px; }
.card {
background: linear-gradient(135deg, #f8fafc, #e2e8f0);
border-radius: 12px; padding: 24px; text-align: center;
}
.card h3 { color: #6b7280; font-size: 0.8rem; text-transform: uppercase; margin: 0; }
.card .value { font-size: 2rem; font-weight: 700; color: var(--primary); }
table { width: 100%; border-collapse: collapse; margin-top: 30px; }
th { background: var(--primary); color: white; padding: 12px; text-align: left; }
td { padding: 10px; border-bottom: 1px solid #e5e7eb; }
</style>
</head>
<body>
<div class='grid'>
<div class='card'><h3>Revenue</h3><div class='value'>$1.2M</div></div>
<div class='card'><h3>Users</h3><div class='value'>45,230</div></div>
<div class='card'><h3>Uptime</h3><div class='value'>99.97%</div></div>
</div>
<table>
<tr><th>Product</th><th>Revenue</th><th>Growth</th></tr>
<tr><td>Enterprise</td><td>$680K</td><td>+12%</td></tr>
<tr><td>Professional</td><td>$356K</td><td>+8%</td></tr>
</table>
</body>
</html>";
var pdf = renderer.RenderHtmlAsPdf(html);
pdf.SaveAs("report.pdf");using IronPdf;
var renderer = new ChromePdfRenderer();
string html = @"
<!DOCTYPE html>
<html>
<head>
<style>
:root { --primary: #2563eb; }
body { font-family: 'Segoe UI', sans-serif; padding: 40px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 20px; }
.card {
background: linear-gradient(135deg, #f8fafc, #e2e8f0);
border-radius: 12px; padding: 24px; text-align: center;
}
.card h3 { color: #6b7280; font-size: 0.8rem; text-transform: uppercase; margin: 0; }
.card .value { font-size: 2rem; font-weight: 700; color: var(--primary); }
table { width: 100%; border-collapse: collapse; margin-top: 30px; }
th { background: var(--primary); color: white; padding: 12px; text-align: left; }
td { padding: 10px; border-bottom: 1px solid #e5e7eb; }
</style>
</head>
<body>
<div class='grid'>
<div class='card'><h3>Revenue</h3><div class='value'>$1.2M</div></div>
<div class='card'><h3>Users</h3><div class='value'>45,230</div></div>
<div class='card'><h3>Uptime</h3><div class='value'>99.97%</div></div>
</div>
<table>
<tr><th>Product</th><th>Revenue</th><th>Growth</th></tr>
<tr><td>Enterprise</td><td>$680K</td><td>+12%</td></tr>
<tr><td>Professional</td><td>$356K</td><td>+8%</td></tr>
</table>
</body>
</html>";
var pdf = renderer.RenderHtmlAsPdf(html);
pdf.SaveAs("report.pdf");Imports IronPdf
Dim renderer As New ChromePdfRenderer()
Dim html As String = "
<!DOCTYPE html>
<html>
<head>
<style>
:root { --primary: #2563eb; }
body { font-family: 'Segoe UI', sans-serif; padding: 40px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 20px; }
.card {
background: linear-gradient(135deg, #f8fafc, #e2e8f0);
border-radius: 12px; padding: 24px; text-align: center;
}
.card h3 { color: #6b7280; font-size: 0.8rem; text-transform: uppercase; margin: 0; }
.card .value { font-size: 2rem; font-weight: 700; color: var(--primary); }
table { width: 100%; border-collapse: collapse; margin-top: 30px; }
th { background: var(--primary); color: white; padding: 12px; text-align: left; }
td { padding: 10px; border-bottom: 1px solid #e5e7eb; }
</style>
</head>
<body>
<div class='grid'>
<div class='card'><h3>Revenue</h3><div class='value'>$1.2M</div></div>
<div class='card'><h3>Users</h3><div class='value'>45,230</div></div>
<div class='card'><h3>Uptime</h3><div class='value'>99.97%</div></div>
</div>
<table>
<tr><th>Product</th><th>Revenue</th><th>Growth</th></tr>
<tr><td>Enterprise</td><td>$680K</td><td>+12%</td></tr>
<tr><td>Professional</td><td>$356K</td><td>+8%</td></tr>
</table>
</body>
</html>"
Dim pdf = renderer.RenderHtmlAsPdf(html)
pdf.SaveAs("report.pdf")This uses CSS Grid with auto-fit/minmax, custom properties, linear-gradient, border-radius, and :root selectors. Every one of these features fails in iText's pdfHTML, breaks in wkhtmltopdf, and doesn't exist in PdfSharp or QuestPDF.
How Do I Migrate from Other Libraries?
For teams migrating from iTextSharp or wkhtmltopdf, IronPDF accepts URLs directly — useful when your existing workflow generates HTML files or serves pages:
using IronPdf;
var renderer = new ChromePdfRenderer();
// Convert from URL — useful when migrating from wkhtmltopdf URL-based workflows
var pdf = renderer.RenderUrlAsPdf("https://localhost:5001/reports/quarterly");
pdf.SaveAs("report.pdf");
// Convert from local HTML file
var pdfFromFile = renderer.RenderHtmlFileAsPdf("templates/invoice.html");
pdfFromFile.SaveAs("invoice.pdf");using IronPdf;
var renderer = new ChromePdfRenderer();
// Convert from URL — useful when migrating from wkhtmltopdf URL-based workflows
var pdf = renderer.RenderUrlAsPdf("https://localhost:5001/reports/quarterly");
pdf.SaveAs("report.pdf");
// Convert from local HTML file
var pdfFromFile = renderer.RenderHtmlFileAsPdf("templates/invoice.html");
pdfFromFile.SaveAs("invoice.pdf");Imports IronPdf
Dim renderer As New ChromePdfRenderer()
' Convert from URL — useful when migrating from wkhtmltopdf URL-based workflows
Dim pdf = renderer.RenderUrlAsPdf("https://localhost:5001/reports/quarterly")
pdf.SaveAs("report.pdf")
' Convert from local HTML file
Dim pdfFromFile = renderer.RenderHtmlFileAsPdf("templates/invoice.html")
pdfFromFile.SaveAs("invoice.pdf")Deployment
IronPDF runs on Windows (x64), Linux (x64, ARM64), macOS (x64, Apple Silicon), and Docker containers. The Docker configuration is a standard .NET image:
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY . .
ENTRYPOINT ["dotnet", "MyApp.dll"]No Chromium installation, no native library dependencies, no sandbox configuration.
Licensing: Perpetual licenses starting at $749. Published pricing at ironpdf.com. No AGPL, no per-document fees, no revenue thresholds.
Performance Benchmarks
Tested on a Standard_D4s_v3 Azure VM (4 vCPU, 16GB RAM) running Ubuntu 22.04 and .NET 8. Test document: a 200-element HTML invoice template with CSS Grid layout, embedded images, and a JavaScript-generated chart. Each measurement averaged over 50 iterations after a 5-iteration warm-up period.
| Scenario | IronPDF | Puppeteer Sharp | iText pdfHTML | wkhtmltopdf |
|---|---|---|---|---|
| Simple HTML (no JS) | ~150ms | ~500ms | ~200ms | ~200ms |
| Complex CSS (Flexbox/Grid) | ~250ms | ~600ms | Broken output | Broken output |
| JavaScript-rendered content | ~350ms | ~800ms | Fails (no JS engine) | Fails/Partial |
| Memory per operation | ~80MB | ~150MB | ~60MB | ~50MB |
| Cold start (first generation) | 2–5s | 3–8s | <1s | <1s |
iText and wkhtmltopdf show faster cold starts because they don't initialize a browser engine. But this comparison is only meaningful for scenarios where all libraries produce correct output — and for complex CSS or JavaScript content, only IronPDF and Puppeteer Sharp produce usable results.
Note: These represent typical observations on the specified hardware. Your performance will vary with HTML complexity, document length, and server resources. Test with your actual workloads before making decisions.
Feature Comparison
| Feature | IronPDF | iText 7 | Puppeteer Sharp | wkhtmltopdf | PdfSharp | QuestPDF | Aspose |
|---|---|---|---|---|---|---|---|
| HTML to PDF | Yes (Chromium) | Limited (CSS 2.1) | Yes (Chrome) | Deprecated | No | No | Limited |
| CSS Flexbox/Grid | Yes | No | Yes | No | No | No | No |
| JavaScript execution | Yes | No | Yes | Limited | No | No | No |
| Cross-platform (no libgdiplus) | Yes | Yes | Yes | N/A | Partial | Yes | No |
| Published pricing | $749+ | No ($15K–$210K/yr) | Free (MIT) | Free | Free (MIT) | Free <$1M | $999+ |
| Active maintenance | Yes | Yes | Yes | Abandoned | Yes | Yes | Yes |
Which Library Should I Choose?
HTML templates with modern CSS → IronPDF provides embedded Chromium without external process management. If your team can manage browser infrastructure, Puppeteer Sharp is a viable alternative.
Programmatic document generation from data, no HTML → QuestPDF offers an elegant fluent API. Don't choose it expecting HTML conversion.
Simple PDF manipulation (merge, split, watermark) → PdfSharp is free and capable for non-HTML tasks.
Avoid for new projects: wkhtmltopdf (abandoned, CVEs), iText without commercial license (AGPL trap), Aspose on Linux (memory leaks).
The key question is whether your workflow uses HTML templates. If it does, only Chromium-based solutions produce correct output with modern CSS. If it doesn't, the choice depends on API preference and licensing constraints.