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:
pdfkitcần shim NodeBuffer. Có thể làm, nhưng thêm khoảng 500 KB và làm compute chậm khoảng 30%.pdf-librấ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.jsPDFbrowser-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à
fetchmột lần ở module-init. - Lớp shim
node:. Nếu bạn thấy__cf_KVhoặcpolyfills: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 -- --analyzetrê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:
- 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
- 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.
- Bạn có load font mỗi request không? Đưa font loading ra module init.
- 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. - 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ó.