บล็อก

สร้าง PDF ใน Cloudflare Workers ช้า? วินิจฉัยได้ใน 5 นาที

Workers เร็วมาก จนกว่าคุณจะยก PDF stack ที่ออกแบบมาสำหรับ server ระยะยาวเข้ามา นี่คือคอขวดจริงบน edge และวิธีเลี่ยง

คุณย้ายบริการ invoice, label หรือ receipt ไปที่ Cloudflare Workers เพราะส่วนอื่นของ stack ก็อยู่ที่นั่นอยู่แล้ว ตัวเลข latency ดูดีมาก: 5 ms ถึง colo ใกล้ที่สุด, 1 ms CPU แล้ว request ก็จบ

จากนั้น PDF generation เข้ามา p99 กลายเป็น 800 ms, worker bundle เตือน 50 MB และเริ่มรู้สึกว่า runtime อาจไม่ใช่คำตอบ ปกติปัญหาไม่ได้อยู่ที่ Workers แต่อยู่ที่ PDF stack ที่ออกแบบมาสำหรับ server ที่อยู่ยาว ถูกนำมาใส่ใน isolate runtime

Workers ไม่ใช่ Lambda

Cloudflare Workers ไม่ใช่ serverless containers แต่เป็น V8 isolates ที่มีข้อจำกัดเฉพาะ:

  • CPU time limit: Free plan ได้ 50 ms ต่อ request, Workers Paid Bundled ได้ 30 seconds, Unbound ได้ 5 minutes. Wall time ยาวได้แต่ billable
  • Memory cap: 128 MB ต่อ isolate
  • Bundle size: Free plan 1 MB, paid 10 MB
  • ไม่มี filesystem. ไม่มี fs.readFileSync; ทุกอย่างอยู่ใน memory หรือมาจาก fetch
  • ไม่มี native binaries. ใช้ได้เฉพาะ pure JavaScript / WebAssembly; ไม่มี node-canvas, native zlib หรือ Ghostscript ผ่าน shell
  • Cold start ประมาณ 5 ms. เร็วมากเพราะไม่มีอะไรใหญ่ให้ boot

ปัญหา “PDF ช้าใน Workers” ส่วนใหญ่เกิดจากชนข้อจำกัดเหล่านี้ โดยเฉพาะ CPU หรือ bundle size

ห้าสิ่งที่ทำให้ช้าจริง

1. พยายามเอา Chromium renderer เข้า Workers

ทางนี้ไม่เวิร์ก Puppeteer ต้องการ Chromium ประมาณ 250 MB และ OS จริง Cloudflare Browser Rendering API หรือ Browserless ใช้ได้ แต่ไม่ใช่ Workers; เป็น service ภายนอกที่ Worker เรียกไป มี round-trip ประมาณ 500 ms บวก render time

ถ้า “PDF ใน Worker” ของคุณจริงๆ คือ “Worker เรียก remote browser” latency floor จะอยู่แถว 500 ms นี่ไม่ใช่ปัญหา Worker แต่เป็น browser tax

Diagnosis: หา fetch("https://browser-rendering.cloudflare.com/...") หรือสิ่งที่คล้ายกัน ถ้ามี แปลว่าคุณกำลังวัด upstream latency

2. ทำ layout ด้วย JavaScript

ถ้าคุณเขียน JS layout engine เองเพื่อคำนวณ box position และ text wrapping คุณจะชน CPU limit JS เร็วก็จริง แต่ layout ของ 30+ elements พร้อม wrapping บน Workers Free เกิน 50 ms ได้ง่าย และบน Bundled อาจไปถึง 100-300 ms

Pipeline แบบนี้แพง:

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

มันทำ CPU-bound pass สี่รอบบน data เดิม ทั้งหมดอยู่ใน JS มี GC pressure และ intermediate tree allocation

Diagnosis: ดู wrangler tail ของ render หนึ่งครั้ง ถ้า CPU เกิน 50 ms ก่อนมี I/O แปลว่า bottleneck คือ compute

3. โหลด fonts ทุก request

Font มักมีขนาด 50-250 KB ถ้า renderer อ่านจาก KV / R2 ในทุก render จะมี network round-trip ต่อ font ต่อ request ห้า fonts คือ 50-150 ms ก่อนเริ่ม render

Diagnosis: ใส่ timing รอบ font-loading code ถ้ามันครอง p50 ปัญหาอยู่ตรงนี้

Fix: โหลด fonts ครั้งเดียวใน module init ที่ top level ของไฟล์ Worker ไม่ใช่ใน request handler isolate จะ cache bytes ไว้ตลอดอายุของมัน

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

ถ้า bundler inline font เป็น bytes ได้ ยิ่งดี: ไม่มี I/O

4. ใช้ JS PDF library ที่ไม่ได้ออกแบบมาสำหรับ Workers

pdfkit, pdf-lib, jsPDF ทำงานใน Workers ได้ แต่มีต้นทุน:

  • pdfkit ต้องใช้ Node Buffer shims. ใช้ได้ แต่เพิ่มประมาณ 500 KB และทำ compute ช้าลงราว 30%
  • pdf-lib ดีมากสำหรับแก้ไข PDF เดิม แต่ไม่เหมาะเท่ากับการ emit จากศูนย์; abstraction เพิ่ม overhead ประมาณ 10 ms ต่อ page
  • jsPDF เป็น browser-first มีปัญหา Buffer เดียวกัน และ API surface ใหญ่จน tree-shake ยาก

สำหรับ pipeline “อ่าน JSON แล้วเขียน PDF bytes” engine เฉพาะทางที่ emit PDF โดยตรงมักเร็วกว่า 5-20 เท่า Rust หรือ C++ ที่ compile เป็น WebAssembly เหมาะกับ tight loops

5. Bundle แอบโตเป็น 4 MB

Workers Free จำกัด bundle ที่ 1 MB, Workers Bundled ที่ 10 MB หลายทีมรู้ตอน wrangler deploy ฟ้อง “Script exceeds size limit” บางทีมเห็นก่อนจาก cold start ที่ช้าลง เพราะ V8 ต้อง compile code มากเกินไป

wrangler แสดง bundle size ใน deploy output อะไรก็ตามที่เกิน 500 KB ควรตรวจ:

  • Bundled fonts. ย้ายไป Workers Assets แล้ว fetch ครั้งเดียวใน module init
  • node: shim layer. ถ้า source map เห็น __cf_KV หรือ polyfills: แปลว่า bundler กำลัง shim Node APIs ที่อาจไม่จำเป็น
  • Unused dependencies. Wrangler 4+ ใช้ npm run build -- --analyze เพื่อดู treemap ได้

PDF ที่เร็วใน Workers หน้าตาเป็นอย่างไร

Edge-native renderer สำหรับ structured documents เช่น gPdf มักอยู่ในช่วงนี้:

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 + a few microseconds of crypto for PDF object IDs
Bundle size 4-6 MB Renderer + bundled fonts (Latin + CJK NotoSans)
Memory peak 8-20 MB Document tree + emitted PDF buffer

เทียบกับทาง “Worker เรียก remote Puppeteer/browser rendering”: p50 500-1000 ms, browser memory 1-2 GB อยู่ใน service อื่น และ upstream cost ประมาณ $0.001/render

Triage แบบเร็ว

ถ้า PDF ใน Workers ช้าอยู่ตอนนี้ ให้เช็กก่อน:

  1. เวลาไปอยู่ตรงไหน? ใส่ timestamps ใน wrangler tail แล้วแยก CPU, upstream fetch และ cold start
  2. มี JS layout หรือไม่? ถ้ามี นั่นน่าจะเป็น CPU หลัก ใช้ renderer ใน isolate เดียวกัน
  3. โหลด fonts ต่อ request หรือไม่? ย้ายไป module init
  4. เรียก external browser หรือไม่? latency floor คือ service นั้น ใช้ same-isolate rendering
  5. Bundle เกิน 1 MB หรือไม่? cold start โตตาม bundle size ตัด unused deps

PDF ที่เร็วที่สุดใน Workers คือ document data กลายเป็น PDF bytes ภายใน fetch handler call เดียว ไม่มี fetch() ภายใน และไม่มี JS layout หนัก

สรุปสั้น

Cloudflare Workers สามารถ render PDF ในระดับ single-digit milliseconds ได้ ถ้า renderer ถูกออกแบบมาสำหรับ isolate runtime แต่ JS PDF libs สำหรับ Node, remote browser services, fonts ต่อ request และ JavaScript layout จะดัน p50 ให้ช้ากว่า origin

ถ้าไม่อยากสร้าง pipeline นี้เอง ลอง gPdf Playground มันทำงานบน edge runtime แบบเดียวกัน กด Render PDF แล้วดู Network tab; นั่นคือความเร็วของ Workers เมื่อ pipeline ไม่สู้กับ environment