Blog

Tạo PDF chậm trong Cloudflare Workers? Chẩn đoán trong 5 phút

Workers rất nhanh, cho đến khi bạn đưa vào đó một PDF stack vốn được thiết kế cho server sống lâu. Các nút thắt thật sự, và cách bỏ qua chúng, khi bạn cần PDF tại Edge.

Bạn chuyển invoice/label/receipt service sang Cloudflare Workers vì phần còn lại của stack đã chạy ở đó, và phép tính latency trông rất đẹp: 5 ms tới colo gần nhất, 1 ms CPU, request xong.

Rồi PDF generation rơi vào tay bạn, và đột nhiên bạn nhìn thấy p99 800 ms, cảnh báo worker bundle 50 MB, cùng cảm giác dai dẳng rằng mình đang dùng sai công cụ. Đây là lý do chuyện đó xảy ra, và các nút thắt thật sự có thể sửa trong một buổi chiều.

Workers không phải Lambda, và điều đó quan trọng

Trước khi chẩn đoán, cần hiểu đúng runtime model. Cloudflare Workers KHÔNG phải serverless containers. Chúng là V8 isolates với các ràng buộc sau:

  • CPU time limit: 50 ms mỗi request ở free plan, 30 giây trên Workers Paid (Bundled), 5 phút trên Unbound. Wall time không giới hạn nhưng billable.
  • Memory cap: 128 MB mỗi isolate.
  • Bundle size: 1 MB cho free plan, 10 MB trên paid.
  • Không filesystem. Không fs.readFileSync. Mọi thứ nằm trong memory hoặc fetch từ ngoài.
  • Không native binaries. Chỉ pure JavaScript / WebAssembly: không node-canvas, không native zlib call, không shell out tới Ghostscript.
  • Cold-start khoảng 5 ms. Rất nhanh, nhưng chỉ vì không có thứ gì lớn để boot.

Phần lớn vấn đề “PDF chậm trong Workers” đến từ việc vi phạm một trong các ràng buộc này, thường là CPU limit hoặc bundle size, rồi bị throttle âm thầm.

Năm thứ thật sự làm chậm

Theo thứ tự tương đối về tần suất gây đau cho các đội:

1. Kéo renderer dựa trên Chromium vào Workers

Việc này không chạy, chấm hết. Puppeteer cần khoảng 250 MB Chromium và một OS thật. Dịch vụ browser-rendering như Browser Rendering API của Cloudflare hoặc Browserless có thể chạy, nhưng chúng không phải Workers; chúng là service riêng mà Worker của bạn gọi tới, trả thêm khoảng 500 ms round trip + thời gian render.

Nếu “PDF chạy trên Worker” của bạn thật ra là “Worker gọi một remote browser-rendering API”, latency floor của bạn khoảng 500 ms. Đó không phải vấn đề Workers; đó là browser tax đi theo bạn ra Edge.

Chẩn đoán: kiểm tra code có fetch("https://browser-rendering.cloudflare.com/...") hoặc tương tự không. Nếu có, latency bạn đang đo là của upstream service, không phải của Worker.

2. Làm layout bằng JavaScript

Nếu bạn tự viết JS layout engine kiểu “tôi tự tính box position”, bạn đang đụng CPU limit. JS nhanh, nhưng layout cho hơn 30 element có text wrapping rất dễ vượt 50 ms trên Workers Free, và 100-300 ms trên Bundled.

Một render pipeline có hình dạng:

JSON → JS layout pass → SVG generation → SVG-to-PDF library → emit

…đang thực hiện bốn pass CPU-bound trên cùng dữ liệu. Pass nào cũng bằng JS, pass nào cũng có overhead garbage collection, pass nào cũng re-allocate intermediate trees.

Chẩn đoán: tìm log wrangler tail cho một render. Nếu bạn thấy hơn 50 ms CPU trước bất kỳ I/O nào, đó là vấn đề compute.

3. Load font ở mỗi request

Font thường 50-250 KB mỗi file. Nếu renderer đọc từ KV / R2 ở mỗi render, bạn trả một network round trip cho mỗi font, mỗi request. Năm font nghĩa là năm RTT, tức 50-150 ms trước khi render bắt đầu.

Chẩn đoán: thêm timing vào font-loading code. Nếu nó chiếm p50, đây là vấn đề.

Cách sửa: load font một lần ở module-init time, tức top của Worker file, KHÔNG nằm trong request handler. Isolate cache bytes trong suốt vòng đời isolate, từ vài phút tới vài giờ.

// 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 });
  }
};

Nếu bundler inline font thành bytes thì càng tốt: không I/O nào cả.

4. Dùng JS PDF library không được xây cho Workers

pdfkit, pdf-lib, jsPDF đều có thể chạy trong Workers, nhưng có đặc tính gây đau:

  • pdfkit cần shim Node Buffer. Có thể làm, nhưng thêm khoảng 500 KB và làm compute chậm khoảng 30%.
  • pdf-lib rất tốt để edit PDF có sẵn, nhưng kém hơn khi emit từ đầu; abstraction layer thêm khoảng 10 ms overhead mỗi trang.
  • jsPDF browser-first; cùng vấn đề Buffer, cộng thêm API surface rất rộng và khó tree-shake.

Với render pipeline chủ yếu là “đọc JSON, ghi PDF bytes”, một engine purpose-built emit PDF trực tiếp, không đi qua abstraction PDF tổng quát, sẽ nhanh hơn 5-20 lần. WebAssembly engine compile từ Rust hoặc C++ còn hưởng lợi từ tight loop thân thiện với JIT.

5. Bundle bí mật đã 4 MB

Workers Free giới hạn bundle 1 MB. Workers Bundled giới hạn 10 MB. Phần lớn đội phát hiện cap khi wrangler deploy reject với “Script exceeds size limit”. Một số phát hiện sớm hơn, khi import khổng lồ làm cold-start chậm vì V8 phải compile toàn bộ.

wrangler sẽ cho biết bundle size trong output deploy. Bất kỳ thứ gì trên 500 KB đều đáng điều tra. Thủ phạm phổ biến:

  • Font bundle. Chuyển chúng sang Workers Assets và fetch một lần ở module-init.
  • Lớp shim node:. Nếu bạn thấy __cf_KV hoặc polyfills: trong source map, bundler đang shim Node APIs mà bạn không thật sự cần.
  • Dependency không dùng. npm run build -- --analyze trên Wrangler 4+ cho treemap.

PDF nhanh trong Workers trông như thế nào

Một renderer Edge purpose-built cho tài liệu có cấu trúc, gPdf là một ví dụ nhưng kiến trúc áp dụng cho bất kỳ renderer được xây tốt nào:

Metric Thường thấy Vì sao
Cold-start 5-20 ms V8 isolate boot + lần load đầu của WASM module
CPU mỗi lần render 1-4 ms WASM tight loop, không GC pressure
Wall time mỗi lần render 3-8 ms CPU + vài µs crypto cho PDF object IDs
Bundle size 4-6 MB Renderer + font bundle, Latin + CJK NotoSans
Memory peak 8-20 MB Document tree + emitted PDF buffer

So với đường “Puppeteer-on-Workers qua remote browser rendering” điển hình: p50 500-1000 ms, browser memory 1-2 GB được host ở nơi khác, upstream cost khoảng 0,001 USD/render.

Triage nhanh

Nếu bạn đang gặp PDF chậm trong Workers, hãy chạy checklist này trước:

  1. Thời gian đi đâu? Thêm timestamp trong wrangler tail. Xác định bottleneck là:
    • CPU, tức công việc in-process
    • Egress fetch, tức gọi upstream service
    • Cold start, chỉ request đầu sau một khoảng yên lặng
  2. Bạn có chạy JS layout không? Nếu có, đó là phần lớn CPU của bạn. Chuyển sang renderer pre-compute layout.
  3. Bạn có load font mỗi request không? Đưa font loading ra module init.
  4. Bạn có gọi browser bên ngoài không? Khi đó latency floor là response time của service đó. Chuyển sang renderer cùng isolate, không fetch.
  5. Bundle có quá 1 MB không? Cold-start scale theo bundle size. Cắt dependency không dùng.

PDF nhanh nhất có thể trên Workers là đường nơi document data -> emitted PDF bytes xảy ra hoàn toàn trong một lần gọi fetch handler, không có fetch() nội bộ và không có layout JS nặng CPU. Phần lớn phàn nàn “Workers chậm cho PDF” mà chúng tôi nghe thật ra là “chúng tôi đặt một PDF stack không hợp hình Workers lên Workers và nhận phần tệ của cả hai thế giới”.

Phiên bản ngắn

Cloudflare Workers có thể render PDF ở mức single-digit milliseconds, nhưng chỉ khi renderer được xây cho runtime hình isolate. JS PDF libs thiết kế cho Node, dịch vụ browser-rendering gọi từ Worker, font load mỗi request, layout pass bằng JavaScript: mỗi thứ đều đẩy p50 của bạn tới đâu đó giữa “chậm hơn origin” và “không khác gì regional Lambda”.

Nếu bạn không muốn tự xây đường thoát khỏi vấn đề này, gPdf Playground là renderer deploy hoàn toàn tại Edge, chạy trên đúng runtime này. Bấm Render PDF, nhìn network tab: p50 đó là điều một Worker có thể làm khi không phần nào trong pipeline chống lại nó.