Arisecraft Logo

Scaling PDF Generation at the Edge: Streaming to R2 to Bypass Cloudflare Workers Memory Limits

Arisecraft Team
#cloudflare#serverless#pdf#r2#puppeteer#tutorial
Scaling PDF Generation at the Edge: Streaming to R2 to Bypass Cloudflare Workers Memory Limits

Scaling PDF Generation at the Edge: Streaming to R2 to Bypass Cloudflare Workers Memory Limits

Generating PDFs dynamically is a staple requirement for modern web applications. Whether it's generating invoices, user certificates, polished analytical reports, or shipping labels, developers need a reliable engine that can transform HTML and CSS templates into pixel-perfect PDF documents.

Historically, this required running a dedicated server or container cluster loaded with Headless Chrome (Puppeteer or Playwright), managing complex autoscaling policies, and absorbing high baseline costs. Alternatively, developers turned to expensive third-party APIs.

With the advent of Cloudflare Browser Rendering, we can now spin up headless Chrome instances directly on the edge, paying only for the actual browser-minutes consumed.

However, running headless browser operations inside serverless functions introduces a major bottleneck: memory limits. Standard Cloudflare Workers are capped at 128MB of memory. If your application needs to render large HTML templates—such as a 100-page financial audit or an image-heavy catalog (up to 50MB)—holding these massive string payloads in memory will instantly trigger an Out Of Memory (OOM) crash.

In this guide, we'll look at the architecture of Arisecraft's open-source pdf-service and learn how to bypass these memory limits entirely using a Zero-Memory-Copy Streaming Pipeline to Cloudflare R2, combined with double-verification security token handshakes.


The Core Challenge: Serverless Memory Constraints

When a user triggers a PDF generation, they send a raw HTML payload to your HTTP endpoint. In a traditional worker:

  1. The Worker receives the request and parses the body via request.text() or request.json().
  2. This parses the entire HTML content into a memory buffer.
  3. The Worker launches Puppeteer and attempts to feed this string to the browser context.

If your input HTML payload is 20MB, the string representation alone consumes 20MB. Standard V8 garbage collection and intermediate variable copying (converting strings to Uint8Arrays, setting up browser websocket buffers) can easily balloon that memory footprint by 4x to 6x. In a 128MB sandbox, this spells immediate disaster.

To build a production-grade service, we must ensure that the Worker never holds the entire HTML payload in its active V8 heap memory.


The Solution: A Zero-Memory-Copy Streaming Pipeline

By leveraging Cloudflare R2 (S3-compatible object storage) and Cloudflare's native streaming APIs, we can bypass memory allocations entirely. Here is how the workflow is structured:

Loading diagram...

Why this works:

  • Direct Streaming: When the client sends the HTTP request, the Worker acts as a pure pass-through stream. It pipes the incoming request.body (a ReadableStream) directly into the R2 upload stream (R2Bucket.put()). The data flows from the network card directly to storage, keeping the V8 heap usage constant at just a few kilobytes.
  • Ephemeral Staging: The HTML template is staged in R2 only for the duration of the render.
  • Auto-Cleanup: A finally block guarantees that the object is deleted from R2 immediately after Chrome finishes rendering the PDF, keeping R2 storage costs at absolute zero.

Architectural Deep Dive & Code Implementation

Let’s walk through the core components of the service. You can follow along with the files inside the pdf-service repository.

1. Zero-Memory-Copy Storage Stream (src/services/storage.ts)

Instead of parsing the body as text, we pass the raw ReadableStream directly to R2. We also generate a single-use cryptographically secure random token (UUID) and save it in the object's customMetadata. This ensures that only our authorized browser instance can retrieve the file.

1export interface StagedHTML { 2 id: string; 3 token: string; 4} 5 6export class StorageService { 7 private bucket: R2Bucket; 8 9 constructor(bucket: R2Bucket) { 10 this.bucket = bucket; 11 } 12 13 async stageHTML(stream: ReadableStream, contentType: string): Promise<StagedHTML> { 14 const id = `templates/${crypto.randomUUID()}.html`; 15 const token = crypto.randomUUID(); 16 17 // Zero-Memory-Copy: pipe the stream directly into R2 18 await this.bucket.put(id, stream, { 19 httpMetadata: { contentType: 'text/html; charset=utf-8' }, 20 customMetadata: { token } 21 }); 22 23 return { id, token }; 24 } 25 26 async retrieveHTML(id: string): Promise<R2ObjectBody | null> { 27 return await this.bucket.get(id); 28 } 29 30 async deleteHTML(id: string): Promise<void> { 31 await this.bucket.delete(id); 32 } 33}

2. Double-Verification Token Handshake (src/handlers/render.ts)

Once the HTML is uploaded, the Worker launches Puppeteer and navigates to the render endpoint:

https://<your-worker>.workers.dev/render/templates/<uuid>.html?token=<token>

When Chrome makes this GET request, the handler verifies the token against the R2 metadata before streaming the HTML file back to the browser context:

1export async function handleRender( 2 request: Request, 3 id: string, 4 storage: StorageService 5): Promise<Response> { 6 const url = new URL(request.url); 7 const urlToken = url.searchParams.get('token') || request.headers.get('x-render-token'); 8 9 if (!urlToken) { 10 return new Response('Unauthorized: Missing security token', { status: 401 }); 11 } 12 13 const file = await storage.retrieveHTML(id); 14 if (!file) { 15 return new Response('Template not found or already expired', { status: 404 }); 16 } 17 18 // Verify that the requester has the cryptographically secure token 19 const storedToken = file.customMetadata?.token; 20 if (storedToken !== urlToken) { 21 return new Response('Forbidden: Invalid security token', { status: 403 }); 22 } 23 24 // Stream the HTML file directly from R2 back to headless Chrome 25 return new Response(file.body, { 26 headers: { 27 'Content-Type': 'text/html; charset=utf-8', 28 'Cache-Control': 'no-store, no-cache, must-revalidate', 29 } 30 }); 31}

3. Orchestrating Puppeteer (src/services/browser.ts)

The worker uses the @cloudflare/puppeteer binding to connect to a browser instance. Chrome is instructed to load the local /render page and wait until all network connections subside (networkidle0) before compiling the PDF document:

1import puppeteer from '@cloudflare/puppeteer'; 2 3export interface PDFOptions { 4 format?: 'A4' | 'Letter' | 'Legal' | 'A3' | 'Tabloid'; 5 landscape?: boolean; 6 printBackground?: boolean; 7 scale?: number; 8 waitUntil?: 'networkidle0' | 'networkidle2' | 'load' | 'domcontentloaded'; 9 margin?: { top?: string; bottom?: string; left?: string; right?: string }; 10} 11 12export async function renderPDF( 13 browserBinding: any, 14 renderUrl: string, 15 options: PDFOptions 16): Promise<Buffer> { 17 // Connect to the Cloudflare Browser Rendering engine 18 const browser = await puppeteer.launch(browserBinding); 19 const page = await browser.newPage(); 20 21 try { 22 // Navigate to the secure rendering page 23 await page.goto(renderUrl, { 24 waitUntil: options.waitUntil || 'networkidle0', 25 timeout: 30000, 26 }); 27 28 // Generate the PDF binary buffer 29 const pdfBuffer = await page.pdf({ 30 format: options.format || 'A4', 31 landscape: options.landscape || false, 32 printBackground: options.printBackground ?? true, 33 scale: options.scale || 1.0, 34 margin: options.margin || { top: '0.4in', bottom: '0.4in', left: '0.4in', right: '0.4in' }, 35 }); 36 37 return Buffer.from(pdfBuffer); 38 } finally { 39 // Ensure browser resources are cleaned up instantly 40 await browser.close(); 41 } 42}

4. Putting It All Together: The Pipeline Coordinator (src/handlers/generate.ts)

The /generate endpoint ties everything together. It coordinates the R2 staging, Puppeteer render call, and ensures the staged file is deleted in a finally block:

1export async function handleGenerate( 2 request: Request, 3 env: Env 4): Promise<Response> { 5 const storage = new StorageService(env.PDF_TEMP_BUCKET); 6 let staged: StagedHTML | null = null; 7 8 try { 9 // 1. Stage the incoming HTML stream directly to R2 (Zero-Memory-Copy) 10 staged = await storage.stageHTML(request.body, 'text/html'); 11 12 // 2. Generate the secure rendering URL for Chrome 13 const workerUrl = new URL(request.url); 14 const renderUrl = `${workerUrl.origin}/render/${staged.id}?token=${staged.token}`; 15 16 // 3. Extract layout/print parameters from search query params 17 const pdfOptions = parseQueryParams(workerUrl.searchParams); 18 19 // 4. Trigger headless Chrome printing 20 const pdfBinary = await renderPDF(env.MY_BROWSER, renderUrl, pdfOptions); 21 22 // 5. Send PDF file back to the client 23 return new Response(pdfBinary, { 24 headers: { 25 'Content-Type': 'application/pdf', 26 'Content-Disposition': `attachment; filename="${pdfOptions.filename || 'document.pdf'}"`, 27 } 28 }); 29 30 } catch (error: any) { 31 return new Response(JSON.stringify({ error: error.message }), { status: 500 }); 32 } finally { 33 // 6. AUTO-CLEANUP: Always delete the temporary HTML template from R2 34 if (staged) { 35 await storage.deleteHTML(staged.id); 36 } 37 } 38}

How to Deploy Your Own PDF Service

If you want to run this service yourself, the setup is incredibly simple thanks to Bun and Wrangler.

1. Prerequisites

Ensure you have Bun installed locally on your machine.

2. Enable Cloudflare Browser Rendering

  1. Log in to your Cloudflare Dashboard.
  2. Navigate to Compute > Browser Rendering.
  3. Click Enable. (Includes a generous free tier of 10 browser-minutes per day).

3. Create the R2 Storage Bucket

Run the following wrangler command to provision your temporary storage bucket:

1bunx wrangler r2 bucket create pdf-generator-html-temp

4. Clone and Configure

Clone the repo and configure your bindings in wrangler.toml:

1name = "pdf-service" 2main = "src/index.ts" 3compatibility_date = "2025-08-20" 4 5[[r2_buckets]] 6binding = "PDF_TEMP_BUCKET" 7bucket_name = "pdf-generator-html-temp" 8 9[browser] 10binding = "MY_BROWSER"

5. Local Dev and Deploy

To test locally (proxied through Cloudflare's remote browser session):

1bun install 2bun run dev

Deploy globally to the edge:

1bun run deploy

Interactive Testing Sandbox

Once deployed, hitting the root URL (/) of your worker serves a beautiful interactive playground dashboard. You can paste raw HTML, customize margins, formats, orientation, scale, and click Generate to watch the streaming pipeline in action and instantly download the compiled PDF!

1# Example cURL command for testing: 2curl -X POST "https://pdf-service.YOUR_SUBDOMAIN.workers.dev/generate?format=Letter&landscape=false" \ 3 -H "Content-Type: text/html" \ 4 --data-binary "@invoice_template.html" \ 5 --output my_compiled_invoice.pdf

Conclusion

By treating inputs as native streams and staging them in Cloudflare R2 with cryptographically signed tokens, we successfully worked around the 128MB Workers memory limit. We can now generate massive PDF documents without paying for heavy-duty server instances.

The pdf-service is fully open-source. Check out the repository, submit a PR, or star it on GitHub:

👉 https://github.com/Arisecraft/pdf-service