Guide
Fill a PDF form inside a Cloudflare Worker — no Chromium, no Lambda
Update (July 2026): self-serve API keys and paid tiers are now live — free key (250/mo) and plans from $16/mo at /pricing. Pricing/waitlist references below reflect the beta era this post was written in.
You're building on Cloudflare Workers and you need to write values into a PDF form — an invoice, a contract, a government form. You reach for the thing you used last time: headless Chrome, or a PDF library bundled into a Lambda. On Workers, neither one fits. Chrome won't run in a V8 isolate, and standing up a Lambda just to render a PDF drags a whole second runtime into an app that didn't need one. The fill itself is one HTTP call. Here's the minimal Worker, and the reason the hosting substrate — not the PDF code — is the part that actually matters.
Why headless Chrome doesn't fit Workers
The default way to "make a PDF in JavaScript" for the last
decade has been Puppeteer driving headless Chrome: render
HTML, call page.pdf(), done. It works on a
normal Node server or a fat Lambda with a Chromium layer.
It does not work on Cloudflare Workers, and the reason is
architectural, not a missing flag. A Worker runs in a
V8 isolate — the same engine Chrome uses,
but with no operating system underneath it. There's no
filesystem, no ability to spawn a subprocess, and a hard
memory and CPU-time budget per request. Headless Chrome is
an entire browser binary that expects all of those things.
You can't bundle a ~150 MB browser into a Worker, and
even if you could, there's nothing to exec() it.
AcroForm filling — writing values into a form-enabled PDF's existing fields — doesn't need a browser at all. The fields are already defined in the PDF; you're setting their values and flattening, which is pure byte manipulation. The work is a poor match for a render engine and a perfect match for a stateless function. The only question is where that function runs.
Why not just put it on Lambda
The usual escape hatch is "keep the PDF work on AWS Lambda
and call it from the Worker." That works, but look at what
it costs you. You're now running two runtimes for one
feature: the Worker that owns the request, and a Lambda that
exists only to hold a PDF library. You inherit Lambda's cold
starts on a path your edge app was specifically built to
keep fast. You're routing edge → us-east-1 →
edge for every document, so a user in Sydney pays a
trans-Pacific round trip to fill a one-page form. And you've
split your deploy: two log streams, two IAM surfaces, two
things to keep in sync. None of that is about PDFs. It's all
substrate drag, bolted onto an app that chose Workers to
avoid exactly this.
The alternative is to treat the fill as what it is — a stateless transform — and call a hosted endpoint that runs on the same kind of globally-distributed substrate your Worker already lives on. The Worker stays the only thing you deploy.
The Worker
Here's the whole thing. It takes a JSON body of field values, fetches an AcroForm template from R2, calls /api/fill-form, and returns the filled PDF. No browser, no second runtime, ~35 lines.
// src/index.ts — Cloudflare Worker (module syntax)
export interface Env {
TEMPLATES: R2Bucket; // bucket holding your blank AcroForm PDFs
}
export default {
async fetch(req: Request, env: Env): Promise<Response> {
if (req.method !== 'POST') {
return new Response('POST a JSON body of field values', { status: 405 });
}
// 1. The values to write into the form's named fields.
const fields = await req.json<Record<string, string>>();
// 2. Pull the blank template from R2 (cached at the edge after first read).
const obj = await env.TEMPLATES.get('invoice-template.pdf');
if (!obj) return new Response('template missing', { status: 500 });
const templatePdf = await obj.arrayBuffer();
// 3. One call to PDFops. Field keys must match the PDF's AcroForm
// field names — use /tools/inspect to list them if you're unsure.
const fd = new FormData();
fd.append('pdf', new Blob([templatePdf], { type: 'application/pdf' }), 'template.pdf');
fd.append('fields', JSON.stringify(fields));
const resp = await fetch('https://pdfops.dev/api/fill-form', { method: 'POST', body: fd });
if (!resp.ok) {
return new Response(`fill failed: ${await resp.text()}`, { status: 502 });
}
// 4. Stream the filled PDF straight back to the caller.
return new Response(resp.body, {
headers: { 'Content-Type': 'application/pdf' },
});
},
};
That's the production shape. Bind an R2 bucket named
TEMPLATES in wrangler.toml, drop a
form-enabled PDF into it, wrangler deploy, and
POST a JSON object of field values. You get a filled PDF back
with no second service in the picture. The
resp.body stream means the Worker never buffers
the whole document in memory — it pipes PDFops' response
through, which keeps you well inside the isolate's memory
budget even for large forms.
Two things worth knowing. The field keys have to
match the names baked into the PDF's AcroForm — if you're not
sure what they are, the free
Form-Field Inspector lists every
field name in any PDF you drop on it. And the R2
get() is edge-cached after the first read, so
you're not re-fetching the template on every request — the
steady-state path is just the one fetch to
fill-form.
Where this fits a real app
The bare Worker above is the primitive. In practice the trigger is usually a webhook or a queue, and the output goes to storage plus an email. Those are the same pattern with more wiring around the fill step:
- Billing: a Stripe webhook fires the Worker, which fills an invoice template and stores it in R2 — invoice PDFs from Stripe webhooks on Workers.
- Contracts: a Tally/Typeform submission fills a contract and emails a countersigned copy — signed contracts from a Tally webhook on Workers.
- Batch: a Cron Trigger fills and merges per-customer statement bundles once a month — monthly PDF bundles on a Cron Trigger.
In every one of these the fill is the same single
fetch. What changes is the trigger and the
destination — never the PDF substrate.
When this pattern doesn't fit
- You're generating a PDF from scratch out of HTML/CSS — a designed report with arbitrary layout, not a fixed form. That genuinely is a render job; headless Chrome on a Node server or a managed HTML-to-PDF service is the right tool. fill-form writes into an existing form, it doesn't lay out new pages.
- You need to extract data out of a PDF (the reverse direction — reading field values or OCR). That's a different endpoint;
/api/extract-textis on the roadmap. The feedback form is how I prioritize it. - Your template isn't an AcroForm — it's a flat scan or a print-design PDF with no form fields. Add the fields once in Acrobat / LibreOffice Draw (a one-time step), then it fills like any other.
Try it
The endpoint is live and works against any AcroForm PDF. Before you even write the Worker, prove the fill from your terminal (or in the playground, no terminal needed):
curl -X POST https://pdfops.dev/api/fill-form \
-F "pdf=@invoice-template.pdf" \
-F 'fields={"customer_name":"Acme Corp","invoice_no":"INV-1042","amount_due":"$2,400.00"}' \
-o filled-invoice.pdf
You'll get the filled PDF back. From there the Worker above
is just that same call wrapped in a fetch
handler. You get 100 keyless requests per IP per month, or a
free key (250/mo) from /pricing.
Workers-specific questions, a binding that's fighting you, or an endpoint you wish existed? Drop a note on the feedback form — the message field is the fastest way to influence what ships next.
Related
- Fill a PDF form in JavaScript — the same call outside a Worker
- Fill a PDF form in Node.js — for when the app isn't on the edge
- The /api/inspect endpoint reference — list a template's field names programmatically
- Invoice PDFs at scale — the most common thing Workers end up filling