Introduction
Plenty of apps need to produce PDFs — invoices, reports, statements, tickets — but very few of them should bundle a headless browser to do it. A browser-based renderer adds hundreds of megabytes to your deployment, slows cold starts, and drags Chromium's system libraries into services where they don't belong.
The clean answer is a PDF microservice: one small HTTP service owns the rendering engine, and every other app calls it over HTTP. In this guide we'll build exactly that on Azure Functions using CobaltPDF, and then consume it from a .NET client with the ~50 KB CobaltPDF.Requests package.
Since v1.6.2, the CobaltPDF package bundles Chromium and the Linux system
libraries it depends on — so the whole service deploys to a stock Linux Functions plan as a
plain zip: no custom container, no apt packages, no image to maintain. You get
pixel-perfect Chromium rendering behind an ordinary code deploy.
PdfRequest, renders it with a warm engine pool, and returns the PDF — plus a small .NET
client that calls it with the CobaltPDF.Requests fluent builder. Everything here was deployed and
measured on real Azure infrastructure.
The architecture
The client app stays tiny — it only references the request models. All the heavy lifting (the Chromium engine, the warm browser pool, the PDF post-processing) lives in the Function:
CobaltPDF.Requests lives in your service and calls the engine hosted in a separate Azure Function — no browser ever ships with the client.
Because the wire format is just JSON, the client doesn't even have to be .NET — but the
CobaltPDF.Requests package gives C# clients a strongly-typed, fluent way to build the
request, which is what we'll use here.
Prerequisites
- The .NET 8 SDK (CobaltPDF targets
net8.0). - Azure Functions Core Tools v4 —
funcon your PATH. - The Azure CLI, signed in (
az login) to a subscription you can deploy to. - An Azure region where you have Basic (B-series) App Service quota — more on that in the deploy step.
Step 1 — Create the Functions project
Scaffold a .NET isolated-worker Functions project and add the two CobaltPDF packages — the engine (the renderer) and the Requests models (the shared wire types):
func init PdfService --worker-runtime dotnet-isolated --target-framework net8.0
cd PdfService
dotnet add package CobaltPDF
dotnet add package CobaltPDF.Requests
HttpRequest / IActionResult types — which keeps the render
function below short and idiomatic.
Step 2 — Configure the warm pool
CobaltPDF uses a global, shared pool of warm Chromium browsers. Configure it once at startup so every request reuses a ready browser instead of paying a launch cost. Three details matter for Azure:
- Apply the Azure preset.
CloudEnvironment.ConfigureForAzuresets the Chromium flags that suit App Service infrastructure — call it first, then override what you need. - Pre-warm in the background. The first browser launch takes a couple of seconds; do it off the request path so the host starts immediately.
- Add backpressure.
MaxQueueDepthbounds how many requests may queue once the pool is saturated; beyond that the engine fast-fails with aPoolBusyException, which we turn into a clean HTTP 503 below — far better than letting a traffic spike pile up behind a busy core and time out. (New in 1.6.0; default0= unbounded.)
MaxSize defaults to a host-aware value (the smaller of the vCPU count and what fits
in memory), so you can usually omit it; we pin it to 2 here to be explicit about the B2 plan.
using CobaltPdf;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.Hosting;
var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();
// Configure the global browser pool ONCE, before any render.
CobaltEngine.Configure(o =>
{
CloudEnvironment.ConfigureForAzure(o); // Azure-safe Chromium flags
o.MinSize = 1; // keep one browser warm
o.MaxSize = 2; // B2 = 2 vCPU (the default auto-sizes to the host; we pin it here)
o.MaxQueueDepth = 4; // backpressure: shed extra load as 503 instead of piling up
o.MaxUsesPerBrowser = 25; // recycle less often for steady throughput
});
// Optional: a license key removes the trial watermark (same speed either way).
var license = Environment.GetEnvironmentVariable("COBALT_LICENSE_KEY");
if (!string.IsNullOrWhiteSpace(license))
CobaltEngine.SetLicense(license);
// Launch the warm browser in the background (don't block startup).
EngineWarmup.Begin();
builder.Build().Run();
The warm-up helper kicks off the browser launch once and exposes it as an awaitable task, so the very first render waits for it while every request after it is instant:
using CobaltPdf;
public static class EngineWarmup
{
public static Task Ready { get; private set; } = Task.CompletedTask;
public static void Begin() => Ready = CobaltEngine.PreWarmAsync();
}
Step 3 — Write the render function
The function is small: read a PdfRequest from the body, wait for the pool to be ready, and
call ExecuteAsync, which maps the request onto the fluent API and renders. We surface the
render time in a response header — handy for monitoring — return 503 with
Retry-After when the pool sheds load (so callers retry rather than treating it as a hard
failure), and 502 if a render itself fails:
using CobaltPdf.Requests;
using CobaltPdf;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker;
public class RenderPdf
{
[Function("render")]
public async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Function, "post", Route = "render")] HttpRequest req,
CancellationToken ct)
{
// Only the FIRST request waits here (browser launch); the rest are warm.
await EngineWarmup.Ready;
var sw = System.Diagnostics.Stopwatch.StartNew();
var request = await req.ReadFromJsonAsync<PdfRequest>(ct);
if (request is null || (string.IsNullOrWhiteSpace(request.Url) && string.IsNullOrWhiteSpace(request.Html)))
return new BadRequestObjectResult("Provide a PdfRequest with a Url or Html.");
PdfDocument pdf;
try
{
// new CobaltEngine() leases a ready browser from the shared warm pool.
pdf = await request.ExecuteAsync(new CobaltEngine(), ct);
}
catch (PoolBusyException)
{
// Pool saturated and the queue is full — shed cleanly so callers back off.
req.HttpContext.Response.Headers["Retry-After"] = "2";
return new ObjectResult("Server busy — retry shortly.") { StatusCode = 503 };
}
catch (Exception ex)
{
return new ObjectResult($"Render failed: {ex.Message}") { StatusCode = 502 };
}
req.HttpContext.Response.Headers["X-Render-Ms"] = sw.ElapsedMilliseconds.ToString();
return new FileContentResult(pdf.BinaryData, "application/pdf") { FileDownloadName = "render.pdf" };
}
}
ExecuteAsync? It maps every property of the serialized
PdfRequest — paper size, margins, header/footer, watermark, encryption, cookies — onto the
engine's fluent API for you, so your clients build requests declaratively and never touch the
rendering engine directly.
Step 4 — Provision and deploy
Create a resource group, a storage account, and a B2 Linux App Service plan, then a Function app on it. Enable Always On so the warm pool survives between requests:
# Adjust names (storage + app must be globally unique) and region
RG=pdf-service-rg
LOC=westeurope
ST=pdfservicest$RANDOM
PLAN=pdf-service-b2
APP=pdf-service-$RANDOM
az group create -n $RG -l $LOC
az storage account create -n $ST -g $RG -l $LOC --sku Standard_LRS
# B2 = 2 vCPU / 3.5 GB — the recommended minimum (see Production notes)
az functionapp plan create -g $RG -n $PLAN -l $LOC --sku B2 --is-linux
az functionapp create -g $RG --plan $PLAN -n $APP -s $ST \
--runtime dotnet-isolated --runtime-version 8.0 --functions-version 4
# Always On keeps the pool warm between requests
az functionapp config set -g $RG -n $APP --always-on true
az functionapp config appsettings set -g $RG -n $APP --settings \
COBALT_LICENSE_KEY="YOUR-LICENSE-KEY"
Then publish the code — a plain zip deploy, no container:
func azure functionapp publish $APP
Grab the function key so clients can authenticate, and you have your endpoint:
az functionapp keys list -g $RG -n $APP --query "functionKeys.default" -o tsv
# Endpoint:
# https://<APP>.azurewebsites.net/api/render?code=<KEY>
Step 5 — Call it from a client
Now the easy part. In your client app — a web API, a worker, a console tool — install
only CobaltPDF.Requests. No engine, no browser:
dotnet add package CobaltPDF.Requests
Build the request with the fluent builder, POST it to your endpoint, and save the PDF the service streams back:
using CobaltPdf.Requests;
using System.Net.Http.Json;
var endpoint = "https://YOUR-APP.azurewebsites.net/api/render?code=YOUR-KEY";
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(3) };
// Fluent builder — reads the same as rendering with the engine directly
var request = PdfRequest.ForUrl("https://example.com")
.WithPaperFormat("A4")
.WithMargins("15mm")
.WithHeader("<div style='font-size:9px;text-align:center;width:100%'>My Report</div>")
.WithFooter("Page <span class='pageNumber'></span> of <span class='totalPages'></span>")
.WithMetadata(m => { m.Title = "Report"; m.Author = "PDF Service"; })
.Build();
var resp = await http.PostAsJsonAsync(endpoint, request);
resp.EnsureSuccessStatusCode();
byte[] pdf = await resp.Content.ReadAsByteArrayAsync();
await File.WriteAllBytesAsync("report.pdf", pdf);
var ms = resp.Headers.GetValues("X-Render-Ms").First();
Console.WriteLine($"Saved {pdf.Length / 1024} KB in {ms} ms");
That's the whole round trip. The builder covers the entire model — WithLandscape,
WithWatermark, WithEncryption, AddCookie,
WithWaitStrategy, WithLazyLoadPages, and more — and if you prefer plain objects,
a new PdfRequest { … } initializer produces the identical request.
Production notes
A few things worth knowing before you put this in front of real traffic:
- B2 is the recommended minimum. B1 (1.75 GB) works for light, self-contained
HTML you control, but Chromium peaks higher on heavy or image-rich pages and can be OOM-killed on a
B1.
MaxSizeauto-sizes to the host's vCPUs and RAM, andMaxQueueDepthsheds concurrency spikes as clean503s rather than bare, body-less500s from the platform. B2 (3.5 GB) gives a warm worker real headroom; step up to B3 (7 GB) for very large pages. - Latency scales with CPU. Basic-tier cores are modest; for lower per-render latency, a Premium v3 plan's dedicated cores are significantly faster.
- Lazy-loaded images need a nudge. Many sites only load images as you scroll. Set
LazyLoadPagesso the renderer scrolls the page before capture, or setForceEagerImages(engine:WithEagerImages()) to promoteloading="lazy"/data-srcimages without scrolling, which is often faster. - Account for every second. With logging enabled
(
CobaltEngine.LoggingModeorOnBrowserLog), v1.6.4+ emits[Browser TIMING]lines per render stage — browser acquisition, navigation, waits, and PDF capture — so a slow render tells you exactly where the time went. - Watch the pool.
CobaltEngine.GetPoolStatistics()exposes live gauges (workers, leased, idle, queued) and lifetime counters (renders, busy rejections) — wire it to a/statsor health endpoint to see backpressure and capacity at a glance. - Secure the endpoint. We used
AuthorizationLevel.Function(a key in the query string or header). A rendering service can reach any URL on its network, so authenticate every caller and consider allow-listing domains to prevent SSRF. - Add a license key via the
COBALT_LICENSE_KEYapp setting to drop the trial watermark. It's the same render speed either way.
Summary
We built and deployed a complete serverless PDF API:
Stock plan, no container
CobaltPDF 1.6.2+ bundles Chromium and its Linux libraries — a plain zip deploy to a stock Linux Functions plan.
Warm pool, no cold start
Configure the pool once and pre-warm in the background; every request reuses a ready renderer.
One request model
ExecuteAsync maps a serialized PdfRequest onto the engine — clients speak JSON, the service renders.
Featherweight clients
Clients install only the ~50 KB CobaltPDF.Requests package — no Chromium, no native libraries.
Build your PDF service today