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 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:
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
Try it
The endpoint is live and works against any AcroForm PDF. Before you even write the Worker, prove the fill from your terminal:
curl -X POST https://pdfops.dev/api/fill-form \
-F "[email protected]" \
-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. During beta it's 100 requests per IP per month, free, no signup.
Workers-specific questions, a binding that's fighting you, or an endpoint you wish existed? Drop a note on the waitlist form — the message field is the fastest way to influence what ships next.
SOCIAL SHARE CARD GENERATOR