DEV Community

PDFops
PDFops

Posted on • Originally published at pdfops.dev

Fill a PDF form inside a Cloudflare Worker — no Chromium, no Lambda

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' },
    });
  },
};
Enter fullscreen mode Exit fullscreen mode

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:

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 "pdf=@invoice-template.pdf" \
  -F 'fields={"customer_name":"Acme Corp","invoice_no":"INV-1042","amount_due":"$2,400.00"}' \
  -o filled-invoice.pdf
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)