Guide
HTML to PDF in Azure Functions
Azure Functions is a natural fit for on-demand document generation, but running a full rendering engine inside a function is surprisingly painful. Here is a cleaner path that keeps your function thin and fast.
Why a headless browser is a poor fit
The Consumption plan caps each function instance at 1.5 GB of RAM. A Chromium binary alone is around 300 MB before it loads a single page. Under any real load the process hits the memory ceiling and the function host kills it. Cold starts compound the problem: the binary must be decompressed and initialized before the first render, adding several seconds to every cold invocation.
Beyond memory, distributing a large native binary as a function app dependency means managing architecture-specific builds (x86 vs. arm64), layer caching, and keeping the rendering engine patched for security. None of that has anything to do with what your function is actually supposed to do.
HTTP trigger: stream the PDF back to the caller
The function below accepts a JSON body, forwards it to the rendering API, and pipes the binary PDF response straight back. No binary deps, no memory spikes.
// src/functions/generatePdf.ts
import { app, HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions";
app.http("generatePdf", {
methods: ["POST"],
authLevel: "anonymous",
handler: async (req: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
const body = await req.text();
const res = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PDFPIPE_KEY}`,
"Content-Type": "application/json",
},
body,
});
if (!res.ok) {
const { detail } = await res.json() as { detail: string };
return { status: 502, body: detail };
}
const pdf = await res.arrayBuffer();
return {
status: 200,
body: Buffer.from(pdf),
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": 'attachment; filename="document.pdf"',
},
};
},
});The function passes the raw request body through unchanged, so any options your caller sets (page format, margins, header/footer templates) reach the renderer without extra parsing. If the render fails, the API returns a structured error object with a detail field, which the function surfaces as a 502 rather than an opaque crash.
Async delivery: store the PDF and return a URL
For larger documents or when your caller should not wait for the render to finish, pass store: true in the request body. The API renders the document, uploads it to storage, and returns a document_url. Your function can return that URL to the caller immediately, or store it in a queue for downstream processing.
// src/functions/generatePdfAsync.ts
import { app, HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions";
app.http("generatePdfAsync", {
methods: ["POST"],
authLevel: "anonymous",
handler: async (req: HttpRequest, _context: InvocationContext): Promise<HttpResponseInit> => {
const { html, options } = await req.json() as { html: string; options?: Record<string, unknown> };
const res = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PDFPIPE_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ html, options, store: true }),
});
if (!res.ok) {
const { detail } = await res.json() as { detail: string };
return { status: 502, body: detail };
}
// { document_url: "https://..." }
const result = await res.json();
return {
status: 200,
body: JSON.stringify(result),
headers: { "Content-Type": "application/json" },
};
},
});Configuring PDFPIPE_KEY in Azure Portal
Application Settings are the Azure equivalent of environment variables. They are encrypted at rest and injected into the function host at startup.
- Open your Function App in the Azure Portal and navigate to Settings then Environment variables.
- Click Add, set the name to
PDFPIPE_KEY, and paste your API key as the value. - Click Apply then Save. The function app restarts and the key is available via
process.env.PDFPIPE_KEY(Node.js) oros.environ["PDFPIPE_KEY"](Python).
If you use the Azure CLI or Bicep for infrastructure-as-code, set the key with:
az functionapp config appsettings set \
--name <your-function-app> \
--resource-group <your-rg> \
--settings PDFPIPE_KEY=<your-api-key>Python v2 programming model
If your function app runs Python, the v2 programming model uses decorators instead of a separate function.json. The same pattern applies: forward the body, check the status, return the binary.
# function_app.py
import azure.functions as func
import os, requests, json
app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS)
@app.route(route="generate-pdf", methods=["POST"])
def generate_pdf(req: func.HttpRequest) -> func.HttpResponse:
body = req.get_body()
resp = requests.post(
"https://api.pdfpipe.xyz/v1/pdf",
headers={
"Authorization": f"Bearer {os.environ['PDFPIPE_KEY']}",
"Content-Type": "application/json",
},
data=body,
timeout=30,
)
if not resp.ok:
detail = resp.json().get("detail", resp.text)
return func.HttpResponse(detail, status_code=502)
return func.HttpResponse(
resp.content,
status_code=200,
mimetype="application/pdf",
headers={"Content-Disposition": 'attachment; filename="document.pdf"'},
)Add requests to your requirements.txt. The timeout=30 argument prevents the function from hanging indefinitely if the network stalls; adjust it to match your function timeout setting.
Batch rendering with Durable Functions
If you need to render dozens or hundreds of documents in a single job, Durable Functions orchestrators are a natural fit. An orchestrator fans out one activity per document and waits for all of them to complete before returning the full list of URLs. Because each activity is a separate function invocation, renders run in parallel without you managing thread pools or queues.
// orchestrator/index.ts (simplified)
import * as df from "durable-functions";
df.app.orchestration("batchPdfOrchestrator", function* (context) {
const items: string[] = context.df.getInput();
const tasks = items.map((html) =>
context.df.callActivity("renderSinglePdf", html)
);
const urls: string[] = yield context.df.Task.all(tasks);
return urls;
});
df.app.activity("renderSinglePdf", {
handler: async (html: string) => {
const res = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PDFPIPE_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ html, store: true }),
});
const { document_url } = await res.json() as { document_url: string };
return document_url;
},
});The activity uses store: true so each document is uploaded and a URL is returned rather than piping binary data through the orchestrator. Durable Functions replays the orchestrator on restarts, so keep the activity side-effect-free and idempotent.
Pricing
Flat monthly plans. No per-seat fees, no surprise egress charges.
| Plan | Price | Documents / month |
|---|---|---|
| Hobby | Free | 500 |
| Starter | $19 / mo | 5,000 |
| Growth | $49 / mo | 20,000 |
| Scale | $149 / mo | 50,000 |
| Business | $499 / mo | 500,000 |
PDFPipe is the API used throughout this guide. Get a key, paste it into Application Settings, and your first 500 documents are free. There is also a live playground you can try without signing up.
See pricing →