Blog

Generazione PDF lenta in Cloudflare Workers? Diagnosi in 5 minuti

Workers è veloce finché non gli affidate uno stack PDF progettato per server long-lived. I veri colli di bottiglia, e come evitarli, quando servono PDF all'edge.

Avete spostato il servizio di fatture, etichette o ricevute su Cloudflare Workers perché il resto dello stack gira già lì, e la matematica della latenza è bellissima: 5 ms fino al colo più vicino, 1 ms di CPU, richiesta chiusa.

Poi arriva la generazione PDF, e all’improvviso state guardando p99 da 800 ms, warning di worker bundle da 50 MB e la sensazione persistente di usare lo strumento sbagliato. Ecco perché succede e quali colli di bottiglia reali potete risolvere in un pomeriggio.

Workers non è Lambda, e questo conta

Prima della diagnosi, bisogna chiarire il modello runtime. Cloudflare Workers NON sono container serverless. Sono V8 isolates con questi vincoli:

  • CPU time limit: 50 ms per richiesta nel piano Free, 30 secondi in Workers Paid (Bundled), 5 minuti in Unbound. Il wall time non ha lo stesso limite ma viene fatturato.
  • Memory cap: 128 MB per isolate.
  • Bundle size: 1 MB nel piano Free, 10 MB nel piano paid.
  • Niente filesystem. Niente fs.readFileSync. Tutto è in memoria o recuperato via rete.
  • Niente binari nativi. Solo JavaScript puro / WebAssembly: niente node-canvas, niente chiamate zlib native, niente shell out a Ghostscript.
  • Cold start: ~5 ms. Sorprendentemente veloce, ma solo perché non c’è nulla di grande da avviare.

La maggior parte dei problemi “PDF lento in Workers” nasce dalla violazione di uno di questi vincoli, di solito il limite CPU o la dimensione del bundle, con throttling poco visibile.

Le cinque cose che sono davvero lente

In ordine approssimativo di quanto spesso colpiscono i team:

1. Portare motori basati su Chromium dentro Workers

Non funziona, punto. Puppeteer richiede circa 250 MB di Chromium e un vero sistema operativo. I servizi di browser rendering (Cloudflare Browser Rendering API, Browserless) funzionano, ma non sono Workers: sono un servizio separato che chiamate DA un Worker, pagando circa 500 ms di round-trip più il tempo di rendering.

Se il vostro “PDF basato su Worker” è in realtà “Worker che chiama una API browser remota”, il vostro floor di latenza è circa 500 ms. Non è un problema di Worker; è la tassa browser che vi segue fino all’edge.

Diagnosi: controllate se il codice fa fetch("https://browser-rendering.cloudflare.com/...") o qualcosa di simile. Se sì, la latenza misurata è quella del servizio upstream, non del Worker.

2. Fare layout in JavaScript

Se avete scritto un vostro motore di layout JS (“calcolo io le posizioni delle box”), state sbattendo contro il limite CPU. JS è veloce, ma il layout di oltre 30 elementi con text wrapping supera facilmente 50 ms su Workers Free e 100-300 ms su Bundled.

Una pipeline di rendering fatta così:

JSON -> passaggio layout JS -> generazione SVG -> libreria SVG-to-PDF -> emissione

fa quattro passaggi CPU-bound sugli stessi dati. Tutti in JS, tutti con overhead di garbage collection, tutti ricreando alberi intermedi.

Diagnosi: trovate il log wrangler tail di un rendering. Se vedete più di 50 ms di CPU prima di qualsiasi I/O, è un problema di compute.

3. Caricare font a ogni richiesta

I font pesano 50-250 KB ciascuno. Se il motore li legge da KV / R2 a ogni rendering, è un round-trip di rete per font, per richiesta. Cinque font = cinque RTT = 50-150 ms prima ancora di iniziare.

Diagnosi: aggiungete timing al codice di caricamento font. Se domina il p50, il problema è questo.

Fix: caricate i font una volta al module init, in cima al file Worker, NON dentro l’handler della richiesta. L’isolate conserva i byte per tutta la vita dell’isolate (minuti o ore).

// Module-init: runs ONCE per isolate
import notoSans from "./fonts/noto-sans.woff2";
const FONT_BYTES = new Uint8Array(notoSans);

export default {
  async fetch(request) {
    // Per-request: zero font I/O
    return renderPdf({ fontBytes: FONT_BYTES, ...data });
  }
};

Se il bundler incorpora il font come byte, ancora meglio: nessun I/O.

4. Usare una libreria PDF JS non progettata per Workers

pdfkit, pdf-lib, jsPDF funzionano tutti in Workers, ma hanno caratteristiche che fanno male:

  • pdfkit richiede shim Node Buffer. È possibile, ma aggiunge circa 500 KB e rallenta il compute di circa 30%.
  • pdf-lib è ottima per modificare PDF esistenti, meno per emetterli da zero: il livello di astrazione aggiunge circa 10 ms di overhead per pagina.
  • jsPDF nasce per il browser; stesso problema Buffer, più una superficie API ampia e difficile da tree-shake.

Per una pipeline che fa soprattutto “leggi JSON, scrivi byte PDF”, un motore specializzato che emette PDF direttamente, senza passare da una astrazione PDF generica, sarà 5-20× più veloce. I motori WebAssembly compilati da Rust o C++ beneficiano ulteriormente del tight loop favorevole alla JIT.

5. Il bundle che in segreto è diventato 4 MB

Workers Free limita il bundle a 1 MB. Workers Bundled a 10 MB. Molti team scoprono il limite quando wrangler deploy viene rifiutato con “Script exceeds size limit”. Alcuni lo scoprono prima, quando un import enorme rallenta il cold start perché V8 deve compilare tutto.

wrangler vi dice la dimensione del bundle nel suo output di deploy. Qualsiasi cosa oltre 500 KB merita indagine. Colpevoli comuni:

  • Font inclusi nel bundle. Spostateli in Workers Assets e fate fetch una volta al module init.
  • Layer di shim node:. Se vedete __cf_KV o polyfills: nella source map, il bundler sta simulando API Node che forse non vi servono.
  • Dipendenze inutilizzate. npm run build -- --analyze (su Wrangler 4+) produce una treemap.

Com’è un PDF veloce in Workers

Un motore di rendering edge per documenti strutturati (gPdf è un esempio, ma l’architettura vale per qualsiasi motore ben costruito) ha tipicamente questi numeri:

Metric Typical Why
Cold-start 5-20 ms V8 isolate boot + WASM module first-load
Per-render CPU 1-4 ms WASM tight loop, no GC pressure
Per-render wall 3-8 ms CPU + pochi microsecondi di crypto per PDF object IDs
Bundle size 4-6 MB Motore + font bundled (Latin + CJK NotoSans)
Memory peak 8-20 MB Document tree + buffer PDF emesso

Confrontatelo con il tipico percorso “Puppeteer-on-Workers tramite browser rendering remoto”: 500-1000 ms p50, 1-2 GB di memoria browser ospitata altrove, circa 0,001 USD/render di costo upstream.

Triage rapido

Se state affrontando PDF lenti in Workers adesso, eseguite questa checklist prima di qualsiasi altra cosa:

  1. Dove va il tempo? Aggiungete timestamp in wrangler tail. Stabilite se il collo di bottiglia è:
    • CPU (lavoro in-process)
    • fetch verso un servizio esterno
    • cold start (solo la prima richiesta dopo un periodo silenzioso)
  2. State facendo layout JS? Se sì, è la maggior parte della CPU. Passate a un motore che pre-calcola il layout.
  3. State caricando font per richiesta? Spostate il caricamento font al module init.
  4. State chiamando un browser esterno? Allora il vostro floor di latenza è il tempo di risposta di quel servizio. Passate a un motore nello stesso isolate, senza fetch.
  5. Il bundle supera 1 MB? Il cold start cresce con la dimensione del bundle. Tagliate dipendenze inutilizzate.

Il PDF più veloce possibile su Workers è quello in cui i vostri dati documento diventano byte PDF interamente dentro una singola chiamata all’handler fetch, senza chiamate fetch() proprie e senza layout JS pesante. Gran parte delle lamentele “Workers è lento per i PDF” che sentiamo sono in realtà “abbiamo messo su Workers uno stack PDF non adatto a Workers e ottenuto il peggio dei due mondi”.

La versione breve

Cloudflare Workers può renderizzare PDF in pochi millisecondi, ma solo se il motore è costruito per un runtime a isolate. Librerie PDF JS progettate per Node, servizi browser chiamati da un Worker, font caricati per richiesta, passaggi di layout fatti in JavaScript: ognuno sposta il vostro p50 da “più lento dell’origin” a “indistinguibile da una Lambda regionale”.

Se preferite non costruire da soli la via d’uscita, il gPdf Playground è un motore interamente distribuito all’edge che gira su questo stesso runtime. Premete Render PDF, guardate la scheda Network: quel p50 è ciò che un Worker può fare quando nulla nella pipeline combatte l’ambiente.