Cloudflare Workers give you a V8 isolate with no filesystem, no process spawning, and no shell to exec anything, all under a hard memory and CPU-time budget per request [1]. A headless Chrome binary is roughly 150 MB. Even if you could squeeze it past the bundle limit, there is nothing inside a Worker to run it.
A Worker is not a container. It is a V8 isolate with no operating system underneath it. A Chrome binary is roughly 150 MB and cannot be bundled into a Worker, and there is nothing to exec() it even if it fit [1].
Some developers try to work around this by calling out to a browser running somewhere else. That is a valid pattern, but it is not "running Chrome in a Worker." The browser lives on a different host, and your Worker is just a thin proxy.
Cloudflare and the ecosystem have settled on four approaches for PDF generation at the edge.
Browser Run bills only for browser time used and has a generous free tier, but that free tier is bounded. Workers Free plan accounts are limited to 10 minutes of Browser Run usage per day. Paid plans get higher defaults that Cloudflare can raise on request, yet you are still paying for browser minutes with concurrency caps. A burst of invoice generation at month-end can throttle or surprise you.
The /pdf Quick Action sits on the same Browser Run service, so it inherits the same billing model. For a Worker that needs to generate thousands of PDFs without queuing, that cost structure compounds.
Forme removes the browser entirely, with no system libraries to install. The practical constraint on Workers is the free plan's 10 ms CPU cap against a ~20 ms typical render, so real workloads need the paid plan.
Calling AWS Lambda from a Worker for PDF work adds cold starts and a trans-Pacific round trip for distant users [1]. You have defeated the edge by routing through us-east-1. A hosted API colocated with your users avoids that latency, but you still want to be sure you are not buffering multi-megabyte PDFs inside the 128 MB isolate.
A Worker can stream a hosted PDF API's response body straight back to the caller without buffering the whole document in memory [1].
The flow looks like this in practice. Your Worker receives a POST with order data. It fetches a prebuilt HTML invoice template from your own origin or constructs one inline. It POSTs that HTML to the hosted API with headers, footers, and margins specified. The API returns a PDF URL or a buffer. The Worker streams that response directly to R2 or back to the caller, never holding more than a chunk in memory.
No binary to ship, no isolate memory ceiling, no browser-minute billing surprises. The trade-off is a network hop and a dependency on an external service. For most teams already running Workers, that dependency is preferable to maintaining a Chromium fleet.
MarkupGo's /pdf endpoint generates PDFs from URLs, HTML, templates, and markdown via a single POST with source, options, and optional expiration fields [2]. PDF options include custom header/footer HTML, PDF/UA accessibility, emulated media type, wait delays, cookies, and metadata [2]. You can set an expiration option that auto-deletes generated files between 60 seconds and 90 days, with expired tasks swept every minute [2].
For templates you reuse, MarkupGo's Magic Template URL lets you generate images or PDFs from an existing template via a plain URL with query parameters, no code required [3]. Magic URLs are public, so anyone with the URL can consume your credits, but json=true or redirect=true parameters can hide the generated file URL from clients [3]. Reusing the same Magic URL within 15 days serves a cached result without consuming additional credits [3].
Pricing starts at $29/month for the Lite plan with 2,000 credits and 5 templates, $49/month Plus with 10,000 credits, and $99/month Pro with 20,000 credits and unlimited templates [4]. A free trial offers 100 credits per month with no credit card required [4].
Here is a Worker that accepts HTML inline, sends it to MarkupGo, and streams the resulting PDF into R2 without buffering the full file in memory.
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
const { html, filename = 'document.pdf' } = await request.json();
// Call MarkupGo's /pdf endpoint
const pdfResponse = await fetch('https://markupgo.com/api/v1/pdf', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': env.MARKUPGO_API_KEY,
},
body: JSON.stringify({
source: {
type: 'html',
data: html,
},
options: {
properties: {
printBackground: true,
margins: { top: 10, bottom: 10, left: 10, right: 10 },
},
},
expiration: { expiresInSeconds: 3600 },
}),
});
if (!pdfResponse.ok) {
return new Response('PDF generation failed', { status: 502 });
}
const pdfTask = await pdfResponse.json();
// pdfTask.url is the generated PDF file
// Stream the PDF from MarkupGo into R2
const pdfStream = await fetch(pdfTask.url);
if (!pdfStream.ok || !pdfStream.body) {
return new Response('Failed to fetch PDF stream', { status: 502 });
}
const r2Object = await env.PDF_BUCKET.put(filename, pdfStream.body, {
httpMetadata: {
contentType: 'application/pdf',
contentDisposition: `attachment; filename="${filename}"`,
},
});
return new Response(JSON.stringify({
r2Key: r2Object.key,
markupgoTask: pdfTask.id,
}), {
headers: { 'Content-Type': 'application/json' },
});
},
};
The Worker never loads the full PDF into a string or ArrayBuffer. It pipes the response body from MarkupGo directly into R2, staying within the isolate's memory limits even for large documents. The expiration field ensures the generated file is cleaned up from MarkupGo's storage after an hour, while R2 keeps your own copy as long as your retention policy requires.
If you prefer to return the PDF directly to the caller instead of storing it, replace the R2 put with return new Response(pdfStream.body, { headers: { 'Content-Type': 'application/pdf' } }).
For invoices or reports that repeat the same layout, consider creating a template in the MarkupGo dashboard and switching the source type to template. That moves the HTML and CSS out of your Worker code and lets non-developers adjust styling without a deploy. For OG images or social posts, a Magic Template URL can generate the asset from query parameters alone, with caching across repeated requests [3].
Start with the free trial to validate the streaming pattern against your own HTML and R2 bucket.