HTML to Image in C#: PuppeteerSharp or One HttpClient Call
Convert an HTML string to PNG in C# on .NET 8: PuppeteerSharp with SetContentAsync, a browser pool we measured, and the same render with HttpClient.
To convert an HTML string to an image in C#, launch Chrome with PuppeteerSharp, load the string with page.SetContentAsync(html), and call page.ScreenshotAsync("out.png"). That is about ten lines and works on .NET 8. The work starts after those ten lines: Chrome has to be installed and kept alive, user input has to be escaped, and parallel renders need a plan. This post covers each with code we ran on .NET 8, plus the same render as one HttpClient call to an API, so you can compare the two honestly.
This page is about your own HTML (invoices, certificates, social cards) rather than screenshots of websites. For URL screenshots, ASP.NET Core integration, Polly and signed URLs, see the C# screenshot API guide. If you are choosing between the two .NET browser libraries, PuppeteerSharp vs Playwright .NET has measured numbers for both. Both pages sit in the HTML to image API cluster.
Tested on 2026-10-18 with .NET SDK 8.0.426 (runtime 8.0.32) on macOS 27.0.1 (Apple M1 Pro):
PuppeteerSharp25.14.0 driving Chrome for Testing 155.0.8059.79. The API listing ran against a local stub that returns the documented response shapes, not the live API.
Two ways, side by side
| PuppeteerSharp | HttpClient + API | |
|---|---|---|
| NuGet packages | PuppeteerSharp |
None |
| Chrome on the machine | Yes (downloaded or installed) | No |
| First render after start | About 1 s for the launch, then the render | One HTTP round trip |
| Warm render of a 1200 x 630 card | 77 to 81 ms median in our test | Network plus render time on the provider |
| Fonts | Whatever the OS has, plus web fonts | The provider's fonts, plus web fonts |
| Works in Azure App Service, Functions, Alpine containers | Needs extra setup | Yes, it is an HTTP call |
| Cost | Your servers and your time | Per render |
Option 1: PuppeteerSharp
dotnet new console -n HtmlToImage -f net8.0 cd HtmlToImage dotnet add package PuppeteerSharp --version 25.14.0
The template, with escaping
C# 11 raw string literals with $$ interpolation are made for HTML templates. CSS braces stay single, and only {{ }} interpolates:
using System.Net; static string InvoiceCard(string customer, string amount, string due) => $$""" <!doctype html> <html><head><meta charset="utf-8"> <style> html, body { margin: 0; } body { width: 1200px; height: 630px; box-sizing: border-box; padding: 80px; display: flex; flex-direction: column; justify-content: space-between; background: #fdf8ef; color: #1c1917; font-family: Georgia, serif; } .label { font: 600 26px system-ui, sans-serif; letter-spacing: .1em; text-transform: uppercase; color: #a16207; } .amount { font-size: 140px; line-height: 1; margin: 0; } .meta { font: 32px system-ui, sans-serif; color: #57534e; } </style></head> <body> <div class="label">Invoice for {{WebUtility.HtmlEncode(customer)}}</div> <h1 class="amount">{{WebUtility.HtmlEncode(amount)}}</h1> <div class="meta">Due {{WebUtility.HtmlEncode(due)}}</div> </body></html> """;
WebUtility.HtmlEncode turns <, >, &, " and ' into entities. We passed the customer name Northwind <Traders> & Co, and it appeared as text in the PNG. Without the encoding, <Traders> would be parsed as an unknown tag and disappear, and a name containing <img src=x onerror=...> would run script inside your renderer.
Launch, load, capture
// Program.cs: HTML to PNG with PuppeteerSharp on .NET 8. using PuppeteerSharp; // Use a preinstalled Chrome when CHROME_PATH is set (Docker, CI); // otherwise download the matching Chrome for Testing build once. var chromePath = Environment.GetEnvironmentVariable("CHROME_PATH"); if (string.IsNullOrEmpty(chromePath)) { var installed = await new BrowserFetcher().DownloadAsync(); chromePath = installed.GetExecutablePath(); } await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions { Headless = true, ExecutablePath = chromePath, }); await using var page = await browser.NewPageAsync(); await page.SetViewportAsync(new ViewPortOptions { Width = 1200, Height = 630, DeviceScaleFactor = 2 }); var html = InvoiceCard("Northwind <Traders> & Co", "$4,180.00", "1 November 2026"); await page.SetContentAsync(html, new NavigationOptions { WaitUntil = new[] { WaitUntilNavigation.Load } }); await page.EvaluateExpressionAsync("document.fonts.ready"); await page.ScreenshotAsync("invoice.png", new ScreenshotOptions { Type = ScreenshotType.Png }); Console.WriteLine("wrote invoice.png");
What to notice:
SetContentAsync, not a temp file. It loads the string directly. You avoid file permissions, cleanup andfile://rules.- The viewport is the canvas. The template's
bodyis 1200 x 630, and so is the viewport.DeviceScaleFactor = 2doubles the pixels (a 2400 x 1260 PNG) without changing the layout. WaitUntilNavigation.Loadplusdocument.fonts.ready. If the template links a web font,loadusually covers it, and the extra wait protects the case where a font is declared late.await usingcloses the page and the browser even when an exception is thrown. A leaked Chrome process per failed render is the classic way these services run out of memory.
Our runs, with CHROME_PATH pointing at an installed Chrome for Testing:
| Step | Result |
|---|---|
| Launch + first page | 951 ms and 1,003 ms (two runs) |
Warm SetContentAsync + PNG capture, median of 21 |
81 ms and 77 ms |
| Slowest warm render | 100 ms and 103 ms |
| PNG size at 2x | 53,467 bytes |
The launch costs more than ten renders. That is why the next section exists.
Rendering in parallel: more browsers, not more pages
A web app needs to render while other requests are being served. The first idea is one browser and several pages at once. We measured 24 renders of a simple card with different setups:
| Setup | 24 renders took |
|---|---|
| 1 browser, 1 page at a time | 2,323 to 2,378 ms |
| 1 browser, 2 pages in parallel | 2,175 ms |
| 1 browser, 3 pages in parallel | 2,111 ms |
| 1 browser, 4 pages in parallel | Did not finish (stopped after 60 s, twice) |
| 2 browsers, 1 page each | 1,191 to 1,222 ms |
| 4 browsers, 1 page each | 832 to 890 ms |
In our run, extra pages in one browser gave almost nothing. Rendering and screenshot encoding in one Chrome instance were the bottleneck. Four parallel pages stalled with no exception and no timeout; we did not find the cause, so treat it as "this can happen" rather than a known bug. Separate browsers scaled close to linearly up to four on our M1 Pro laptop.
So the pool below hands out whole browsers, one render each at a time:
// HtmlRendererPool.cs: a few long-lived browsers, one render at a time in each. using System.Threading.Channels; using PuppeteerSharp; public sealed class HtmlRendererPool : IAsyncDisposable { private readonly Channel<IBrowser> _idle = Channel.CreateUnbounded<IBrowser>(); private readonly List<IBrowser> _all = new(); public static async Task<HtmlRendererPool> StartAsync(string chromePath, int browsers = 2) { var pool = new HtmlRendererPool(); for (var i = 0; i < browsers; i++) { var browser = await Puppeteer.LaunchAsync(new LaunchOptions { Headless = true, ExecutablePath = chromePath }); pool._all.Add(browser); pool._idle.Writer.TryWrite(browser); } return pool; } public async Task<byte[]> RenderAsync(string html, int width, int height, CancellationToken ct = default) { var browser = await _idle.Reader.ReadAsync(ct); // waits until a browser is free try { await using var page = await browser.NewPageAsync(); // fresh page: no state leaks between renders await page.SetViewportAsync(new ViewPortOptions { Width = width, Height = height, DeviceScaleFactor = 2 }); await page.SetContentAsync(html, new NavigationOptions { WaitUntil = new[] { WaitUntilNavigation.Load }, Timeout = 15_000, }); await page.EvaluateExpressionAsync("document.fonts.ready"); return await page.ScreenshotDataAsync(new ScreenshotOptions { Type = ScreenshotType.Png }); } finally { _idle.Writer.TryWrite(browser); } } public async ValueTask DisposeAsync() { foreach (var browser in _all) await browser.CloseAsync(); } }
The channel is a queue of idle browsers: ReadAsync waits for one, finally puts it back. Register the pool as a singleton and dispose it on shutdown. Each Chrome instance takes its own memory, so size the pool for the container, not the CPU count alone.
Two things the pool leaves to you: restarting a browser that crashed (check browser.IsClosed before use and launch a replacement) and a timeout on the whole render, which ct gives you if the caller passes one.
Running it in a container
On Linux, BrowserFetcher downloads Chrome, but Chrome still needs system libraries and fonts that slim images do not have. The usual fix is an image that installs Chromium and its fonts with the OS package manager, then CHROME_PATH pointing at it. Missing fonts are the most common difference between a laptop render and a server render: system-ui and Georgia in our template map to whatever the container has. Embed or link the fonts you care about. Puppeteer in Docker has the package lists, which are the same for PuppeteerSharp because it drives the same Chrome.
Option 2: HttpClient and an API
If you would rather not run Chrome, send the HTML string to an HTML to image API. There is no official .NET SDK, and you do not need one (an ASP.NET Core version adds a typed client, retries and a queue). This is a complete console app with no NuGet packages:
// Program.cs: HTML to PNG through the RenderScreenshot API with HttpClient. No NuGet packages. using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; var apiKey = Environment.GetEnvironmentVariable("RS_API_KEY") ?? throw new InvalidOperationException("Set RS_API_KEY"); var baseUrl = Environment.GetEnvironmentVariable("RS_API_BASE") ?? "https://api.renderscreenshot.com"; var customer = WebUtility.HtmlEncode("Northwind <Traders> & Co"); var html = $$""" <!doctype html><html><head><meta charset="utf-8"><style> html, body { margin: 0; } body { width: 1200px; height: 630px; display: grid; place-items: center; background: #fdf8ef; font: 700 64px Georgia, serif; color: #1c1917; } </style></head> <body>Invoice for {{customer}}</body></html> """; using var http = new HttpClient { BaseAddress = new Uri(baseUrl), Timeout = TimeSpan.FromSeconds(90), // a render may wait up to 60 s for the page }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); var payload = new { html, viewport = new { width = 1200, height = 630, scale = 2 }, output = new { format = "png" }, }; for (var attempt = 1; ; attempt++) { using var res = await http.PostAsJsonAsync("/v1/screenshot", payload); if (res.IsSuccessStatusCode) { await File.WriteAllBytesAsync("invoice.png", await res.Content.ReadAsByteArrayAsync()); var cdn = res.Headers.TryGetValues("X-Cache-URL", out var v) ? v.First() : "(none)"; Console.WriteLine($"saved invoice.png; CDN copy: {cdn}"); return 0; } var error = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("error"); var code = error.GetProperty("code").GetString(); if (res.StatusCode == HttpStatusCode.TooManyRequests && attempt < 3) { var wait = res.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5); Console.Error.WriteLine($"{code}: retrying in {wait.TotalSeconds} s"); await Task.Delay(wait); continue; } Console.Error.WriteLine($"{(int)res.StatusCode} {code}: {error.GetProperty("message").GetString()}"); return 1; }
Why it looks like this:
PostAsJsonAsyncwith an anonymous object.PostAsJsonAsyncuses the web defaults ofSystem.Text.Json(camelCase names). The anonymous properties are already lowercase, so the JSON has exactly the names the API expects:html,viewport,scale,output. The nested shape matters: a top-levelformatwould be ignored.- A 90 s client timeout. A render can wait up to 60 seconds for the page, and the default
HttpClienttimeout of 100 s is close enough to be confusing. Set it on purpose. - 429 handling with
Retry-After. The rate limit is per account and shared by all its keys, so a busy batch job can hit it. The response says how long to wait; the toomanyrequests page has the details. - Errors exit with code 1 and print the
error.code, which is stable and safe to branch on. The full list is in the error codes reference.
Against our local stub, the program retried once after a 429 and saved the file, and with a wrong key it printed the 401 status and error code and exited with 1. The live API returns missing_credentials for an unknown or mistyped key, so treat every 401 the same way. The JSON body it sent had exactly the keys html, viewport and output. For the same request in other languages, see the raw HTTP guide. For a typed client registered with IHttpClientFactory, use the pattern on the C# screenshot API page; this console version keeps everything in one file.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Failed to launch browser on Linux |
Missing Chrome system libraries | Install Chromium with its dependencies in the image, set CHROME_PATH |
| Every render takes about a second | A new browser per render | Launch once; use the pool above |
| Renders stall when run in parallel | Many pages in one browser | One render per browser at a time; scale with more browsers |
Text missing where a name had < in it |
Value not encoded | WebUtility.HtmlEncode on every inserted value |
{ errors in the template |
$"..." interpolation conflicts with CSS braces |
Raw string literal with $$""" |
| Fonts differ between laptop and server | Container lacks the fonts | Install fonts in the image, or embed them with @font-face |
| White border around the card | Default body margin |
html, body { margin: 0; } |
| Chrome processes left behind | Exceptions skipped CloseAsync |
await using for pages and browsers |
TaskCanceledException from HttpClient |
Client timeout shorter than the render | Timeout of at least 60 s, we use 90 s |
| A setting has no effect in the API call | Flat JSON instead of nested objects | output = new { format = "png" }, not format = "png" at the top |
Which one to choose
- You render a few images per minute, already run Linux containers, and control the fonts: PuppeteerSharp with a small pool of browsers.
- You run on a platform where Chrome is hard (App Service, Functions, Alpine), or render in bursts: the
HttpClientversion. Create a free account for 150 credits a month and run the console app above with your key.
For the request parameters you can add to the payload (base URLs for assets, transparency, waits), see the HTML to image guide.
Related in this topic
- HTML to Image API: Render HTML and CSS to PNG Send HTML and CSS, get a PNG, JPEG, WebP or PDF back from real Chrome. Base URLs for assets, web fonts, transparent backgrounds and signed URLs.
- Best HTML to Image APIs (2026): 9 Options Compared The best HTML to image APIs compared: rendering engine, ready signals, fonts, delivery, public links and prices at three volumes, from each vendor's own docs.
- html2canvas Alternative: Render the DOM in Real Chromium We tested html2canvas 1.4.1 against real Chromium on 14 CSS features. See what it drops, when it is still the right tool, and how to move capture server-side.
- Convert a Webpage to an Image: 5 Methods, Measured Convert a webpage to a PNG, JPEG or WebP five ways, from a DevTools command to an API. Effort, cost and timings for each, plus the settings that matter.
- HTML to Image in Node.js: 4 Ways, Same 6 Templates HTML to image in Node.js with Puppeteer, node-html-to-image, satori and an API, run on the same six templates. Measured times, what broke, and code that works.
- HTML to Image in Python: imgkit, html2image, Playwright Convert HTML to an image in Python with html2image, Playwright or an API, compared on five templates with timings and renders, and why imgkit is a dead end.