Resources / Field guide
Server-side PDF rendering with PuppeteerSharp on Azure
Browser "print to PDF" works until it doesn't — Safari ignores CSS @page sizing, so your one-tall-page export is impossible client-side. The fix is to render the real page to PDF server-side with headless Chromium. Here's the renderer, the Azure deploy trap that silently breaks it, and how to let it load an authenticated page.
Why server-side at all
Client-side printing leans on the browser's print engine, and the browsers don't agree. Chrome and Firefox honor JS-injected @page rules so you can size a single tall page; Safari has no support for CSS @page size, so a "one continuous page" export simply can't be done in the browser there. Rather than ship a broken experience to Safari users, render the exact same page to PDF on the server — one code path, identical output everywhere.
The renderer
PuppeteerSharp drives headless Chromium from .NET. Point it at your real page, wait until it's actually ready (fonts and images loaded), measure, and emit a single tall PDF:
await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true,
ExecutablePath = _chromePath, // set per environment (see below)
Args = new[] { "--no-sandbox", "--disable-dev-shm-usage" }
});
await using var page = await browser.NewPageAsync();
await page.GoToAsync(url, WaitUntilNavigation.Networkidle0);
// Wait for the page to signal it's fully rendered (fonts + images)
await page.WaitForFunctionAsync("() => window.appPageReady === true");
var pdf = await page.PdfStreamAsync(new PdfOptions
{
PrintBackground = true,
Width = "8.5in",
PreferCSSPageSize = true
});
return pdf;
Two details that save you grief: have the page set a flag like window.appPageReady = true once its data and assets are in, and gate the renderer with a semaphore so you only run one Chromium instance at a time — they're memory-hungry and will exhaust an App Service plan if they pile up.
Letting Chromium load an authenticated page
Here's the wrinkle people miss: headless Chromium hits your page as an anonymous client — it has none of the user's cookies, so an [Authorize] page just redirects it to login and you render the sign-in screen. The clean fix is a short-lived, purpose-scoped token:
- When the user requests the PDF, mint a JWT that's valid for a couple of minutes, scoped to exactly this job — e.g. claims
purpose=page-printand the specific record ID. - Append it to the URL Chromium loads (
?printToken=…). - Add middleware that accepts that token only for the one read-only data endpoint the print page needs, and rejects it everywhere else. Replace the page's normal cookie
[Authorize]with a manual check that also accepts the print token.
The token can't be replayed for anything useful (wrong purpose, wrong record, expires in minutes), and the renderer sees real data.
Getting Chromium onto Azure App Service
On Linux App Service you install Chromium at startup and point PuppeteerSharp at it with an app setting:
# Startup command
apt-get update && apt-get install -y --no-install-recommends chromium && dotnet YourApp.dll
# App setting
Puppeteer__ExecutablePath = /usr/bin/chromium
Installing Chromium adds roughly 60–90 seconds to a cold start. Locally, leave the executable path empty and PuppeteerSharp will download a matching Chromium on first render.
The deploy trap that silently breaks it: az webapp deploy resets the startup command back to the default dotnet YourApp.dll, quietly removing your Chromium install. The symptom is brutally specific — the PDF endpoint starts returning an instant 500 while the rest of the API is perfectly fine. After every deploy, re-check and re-apply:
az webapp config show --name <app> --resource-group <rg> --query appCommandLine
Diagnostics note: on locked-down App Service plans, az webapp log tail and SSH may be disabled (SCM basic auth off). Turn on filesystem logging and use the Portal's Log Stream blade instead.
When this fits
Reach for server-side rendering when the PDF must look exactly like a specific app page, when you need cross-browser consistency, or when client print quirks (hello, Safari) are defeating you. It costs you a heavier cold start and a Chromium dependency, so for a simple tabular export a lighter PDF library is the better tool. For pixel-faithful, page-true documents, headless Chromium is hard to beat.
Need pixel-perfect PDFs out of your web app?
I build production PDF and reporting pipelines on .NET and Azure — including the deployment plumbing that keeps them running. Let's talk about what you need to generate.
Work with me