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.WebKit — the lightweight, Linux-native edition of CobaltPDF — and then consume it from a .NET client with the ~50 KB CobaltPDF.Requests package.

This works with either CobaltPDF edition — the code is identical, because both expose the same CobaltEngine type and fluent API. We build it here with the WebKit edition for one reason: it self-provisions a portable render bundle on first start, so it runs on a stock Linux Functions plan with no custom container and idles with lower memory than Chromium — ideal for a long-running serverless service. Prefer maximum rendering fidelity? Use the Chromium edition instead — the very same code, except on Azure Functions Linux it ships in a custom container (it carries a full browser). We flag the handful of edition-specific steps as we go.

What we'll build: an HTTP-triggered Azure Function that accepts a JSON 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 WebKit engine, the warm browser pool, the PDF post-processing) lives in the Function:

HOW IT WORKS YOUR SERVICE web API · worker · desktop app CobaltPDF.Requests ~50 KB · no browser, no engine Builds a PdfRequest AZURE FUNCTION · LINUX Always On · warm pool CobaltPDF engine WebKit or Chromium edition Renders & returns the PDF POST · JSON PDF bytes HTTP
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.WebKit targets net8.0).
  • Azure Functions Core Tools v4func on 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 WebKit engine (the renderer) and the Requests models (the shared wire types):

Terminal
func init PdfService --worker-runtime dotnet-isolated --target-framework net8.0
cd PdfService

dotnet add package CobaltPDF.WebKit      # WebKit edition (used in this guide)
# - or, for the Chromium edition (same API):  dotnet add package CobaltPDF
dotnet add package CobaltPDF.Requests
WebKit or Chromium? The two engine packages share the same API, so everything below works for both — to use Chromium, swap CobaltPDF.WebKit for CobaltPDF and change the namespace (using CobaltPdf.WebKit;using CobaltPdf;). The only real difference is the deploy step: WebKit runs on a stock Linux plan, while Chromium ships in a container.
Tip: the default isolated template uses the ASP.NET Core integration, so HTTP functions use familiar HttpRequest / IActionResult types — which keeps the render function below short and idiomatic.

Step 2 — Configure the warm pool

CobaltPDF.WebKit uses a global, shared pool of warm renderers. Configure it once at startup so every request reuses a ready WebKit instance instead of paying any startup cost. Two details matter for Azure:

  • Put the bundle cache on local disk. On App Service, /home is an Azure Files network share — loading WebKit's many shared libraries from it is slow. /tmp is local SSD.
  • Pre-warm in the background. The first start downloads and extracts the render bundle; do it off the request path so the host starts immediately.
  • Add backpressure. MaxQueueDepth bounds how many requests may queue once the pool is saturated; beyond that the engine fast-fails with a PoolBusyException, 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; default 0 = unbounded.)

MaxSize defaults to a host-aware value in 1.6.0 (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.

Program.cs
using CobaltPdf.WebKit;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.Hosting;

var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();

// Configure the global WebKit pool ONCE, before any render.
CobaltEngine.Configure(o =>
{
    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
    o.RenderTimeout     = TimeSpan.FromSeconds(200); // under Azure's ~230s gateway limit

    if (OperatingSystem.IsLinux())
        o.BundleCacheDirectory = "/tmp/cobaltbundle"; // local SSD, not /home
});

// 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);

// Provision the bundle + warm a browser in the background (don't block startup).
EngineWarmup.Begin();

builder.Build().Run();
Chromium edition: use using CobaltPdf; instead, and drop the BundleCacheDirectory line — Chromium ships its browser inside the NuGet package, so there's no bundle to download or cache. Everything else in this file is identical.

The warm-up helper kicks off provisioning once and exposes it as an awaitable task, so the very first render waits for the bundle while every request after it is instant:

EngineWarmup.cs
using CobaltPdf.WebKit;

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:

RenderPdf.cs
using CobaltPdf.Requests;
using CobaltPdf.WebKit;
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 (bundle download); the rest are warm.
        await EngineWarmup.Ready;

        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"] = (pdf.RenderMilliseconds ?? 0).ToString();
        return new FileContentResult(pdf.BinaryData, "application/pdf") { FileDownloadName = "render.pdf" };
    }
}
Why 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. The same request works against the WebKit edition or the Chromium edition, so your clients never need to know which engine renders on the other end.

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, and give the container a little extra start headroom for the first-boot bundle download:

This is the one step that differs by engine. The WebKit edition deploys to this stock Linux plan with a plain zip deploy — no container — because it self-provisions its bundle. The Chromium edition needs its system libraries, which the stock Functions image doesn't carry, so on Linux you deploy it in a custom container (or use a Windows plan). The plan sizing, Always On, and key steps below are otherwise the same — see the Azure Functions guide for the Chromium container walkthrough.
Azure CLI
# 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 for WebKit (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; the start-time limit covers the first bundle download
az functionapp config set -g $RG -n $APP --always-on true
az functionapp config appsettings set -g $RG -n $APP --settings \
  WEBSITES_CONTAINER_START_TIME_LIMIT=600 \
  COBALT_LICENSE_KEY="YOUR-LICENSE-KEY"
Quota tip: new subscriptions sometimes have zero Basic VM quota in a given region (you'll see "Operation cannot be completed without additional quota"). If the plan creation fails, try another region — Basic quota is per-region — or request an increase in the portal.

Then publish the code — a plain zip deploy, no container:

Terminal
func azure functionapp publish $APP

Grab the function key so clients can authenticate, and you have your endpoint:

Azure CLI
az functionapp keys list -g $RG -n $APP --query "functionKeys.default" -o tsv

# Endpoint:
# https://<APP>.azurewebsites.net/api/render?code=<KEY>
First call after a deploy is slow. Each fresh instance downloads and extracts the WebKit bundle once (a minute or two), then caches it on local disk for its lifetime. With Always On, restarts are rare — but warm up the instance before timing real requests.

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:

Terminal
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:

Client.cs
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.

Not on .NET? The body is just JSON, so any language can call the service. The CobaltPDF.Requests reference has TypeScript and Python examples.

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 heavy or image-rich third-party pages need more memory. In 1.6.0 the engine auto-clamps its soft memory cap to ~80% of detected RAM, so an over-budget render now fails with a clean error (the 502 above) instead of the kernel OOM-killing it; pair that with MaxQueueDepth so concurrency spikes shed as 503 rather than as bare, body-less 500s 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 LazyLoadPages so the renderer scrolls the page before capture, or — new in 1.6.0 — set ForceEagerImages (engine: WithEagerImages()) to promote loading="lazy" / data-src images without scrolling, which is often faster.
  • Watch the pool. CobaltEngine.GetPoolStatistics() exposes live gauges (workers, leased, idle, queued) and lifetime counters (renders, busy rejections) — wire it to a /stats or 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_KEY app setting to drop the trial watermark. It's the same render speed either way.

Summary

We built and deployed a complete serverless PDF API:

WebKit on a stock plan

CobaltPDF.WebKit self-provisions its render bundle — no custom container, just a zip deploy to a 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 — the same request works on WebKit or Chromium.

Featherweight clients

Clients install only the ~50 KB CobaltPDF.Requests package — no Chromium, no native libraries.

Want the full architecture picture? See Microservice Mode for the client/server overview, or the Azure Functions deployment guide for the Chromium edition and container options.

Build your PDF service today

Install CobaltPDF.WebKit Read the Docs