Why we replaced Node.js with Bun for 5x throughput

Bài tóm tắt Trigger.dev 2026 3. Quy mô lớn Độ khó: Trung bình

Trigger.dev · 2026 · 3. Quy mô lớn · Độ khó: Trung bình — bài gốc

Bài của nhà cung cấp; benchmark tự dựng nhưng mô tả rõ và lặp lại ở mọi bước. Có nhắc một agent skill ở cuối, wiki bỏ qua.

Bối cảnh

  • Firestarter là broker "warm start": giữ hàng nghìn kết nối long-poll HTTP từ run controller đang rảnh; khi có task run, ghép với một controller và gửi payload qua kết nối đang giữ (không cold start). Nằm trên đường găng của mọi task. Chạy Node.js và tốn quá nhiều CPU.
  • Benchmark cố định cho mọi bước: 500 controller giả lập giữ kết nối keep-alive, k6 bắn 50 request ghép đồng thời trong 30 giây.

Thay đổi chính (bốn vòng profile)

  1. Bỏ SQLite trong RAM: connection manager được thiết kế như kho query tổng quát (làm phẳng metadata, index trong SQLite tích hợp của Node 22) vì lúc đầu chưa biết mẫu truy cập. Thực tế luôn là 4 trường (deployment, version, CPU, memory); getConnection chiếm 31% CPU. Thay bằng Map với khoá ghép: 2.099 → 4.534 req/s, p50 22,5 → 10,1 ms.
  2. Sang Bun: profile lại thấy hơn 50% CPU nằm trong node:http (writev, quản lý socket, stream) khi giữ hàng nghìn long-poll. Connection manager đã tách khỏi tầng transport nên thêm entry Bun.serve() chủ yếu là nối dây: 9.434 req/s, p50 4,5 ms, max 403 → 22 ms.
  3. Dọn đường nóng (profile dạng markdown của Bun): Zod parse mọi POST chiếm 22% CPU (traffic nội bộ đã được kiểm ở phía gửi, thay bằng kiểm có mặt trường), Object.fromEntries header mỗi GET 10,5%, logger serialize tham số trước khi kiểm mức log 8,6%: tổng cộng giảm khoảng 40% CPU.
  4. Biên dịch thành một binary (bun build --compile): thêm 14% throughput, p95 −24%, image 120 → 68 MB. --bytecode lại chậm hơn với server chạy lâu (chỉ có lợi cho cold start).
  • Tổng: khoảng 5 lần throughput (2.099 → khoảng 10.700 req/s), max latency tốt hơn 28 lần, image 180 → 68 MB.

Bẫy khi đổi runtime

  • Memory leak chỉ có ở mô hình HTTP của Bun: RSS tăng đều sau khi lên production. Khi client ngắt kết nối, handler dọn connection manager nhưng không resolve Promise<Response>; Bun giữ trạng thái request tới khi promise settle (Node/Fastify gắn trạng thái với socket nên tự dọn). Sửa: resolve với 499. Trước đó idleTimeout mặc định 10 giây của Bun cắt long-poll sớm, gấp 6 lần churn và làm leak nhanh hơn; nâng lên 120 giây làm leak chậm lại chứ không hết. Sửa xong CPU tăng khoảng 5% (cái giá của việc dọn đúng).
  • Bun không tự tin CA của cụm Kubernetes (phải đặt NODE_EXTRA_CA_CERTS); AbortSignal bắn hai lần; nhiều metric mặc định của prom-client sai dưới Bun, phải tự làm gauge.
  • Production: bộ nhớ 192 → khoảng 85 MB; CPU và event loop lag phẳng hơn hẳn so với Node.

Vì sao

  • Hai nút thắt đầu là thiết kế quá tổng quát (SQLite cho tra cứu 4 trường) rồi mới tới runtime; đổi runtime chỉ đáng làm sau khi profile chỉ ra tầng HTTP của runtime là phần lớn chi phí (Đổi ngôn ngữ và runtime).

Bài học cho SWE

  • Profile trước, benchmark lặp lại cùng cấu hình ở mỗi bước để biết thay đổi nào có tác dụng.
  • Đổi runtime nghĩa là đổi cả hợp đồng vòng đời ngầm (ở đây: response gắn với promise thay vì socket); duyệt mọi đường kết thúc của request.
  • Môi trường staging với vCPU thật mới là benchmark cuối cùng.

Liên quan