●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Claude API on Bun in Production: Migration Decisions and Implementation Patterns That Actually Survive Real Traffic
A practical guide to running Claude API services on Bun in production. Covers migration triggers from Node.js, built-in SQLite/WebSocket usage, streaming optimization, and the pitfalls that only surface after deployment — with working code and measured numbers.
"Bun is fast, sure — but is it really safe to run a Claude API server on it in production?" I have heard some version of this question more times than I can count since 2025. After migrating a handful of my own side projects from Node.js to Bun in stages, I noticed something the benchmark numbers never quite capture: the real decision is not about raw speed, it is about which production trade-offs you are willing to accept.
With Bun 1.2.x stabilizing and 2026 well underway, I now feel comfortable running Claude API workloads — long-lived streaming included — on Bun. That said, simply replacing node with bun and calling it done leaves a lot of value on the table, and occasionally introduces new failure modes you would not have hit on Node.js.
In this article I want to walk through the patterns I have used to build production-grade Claude API services on Bun, and the operational pitfalls that you only learn about once your service is live. Every code sample here has been run, and is intentionally close to what I would actually deploy.
Why pick Bun for a Claude API server right now
Speed is not the only reason to choose Bun. Let me lay out the specific problems Bun solves for streaming-heavy Claude API workloads.
The first is cold start. When you run Claude API services in serverless or container-based environments, container startup time leaks directly into user-perceived latency. Node.js has improved with v22, but Bun is still less than half of Node's cold start time on my machines. A minimal Claude proxy that takes 220ms to boot on Node starts in 95ms on Bun. When your end-to-end response budget is one to two seconds, that gap is something users actually feel.
The second is native TypeScript execution. Bun runs .ts files directly, with no tsx, no ts-node, no swc build step. That helps in three places at once: faster developer feedback loops, smaller Docker images (no transpile step needed), and one fewer place for transpiler bugs to hide.
The third is the set of built-in primitives. SQLite, WebSocket, a test runner, a package manager, and an HTTP server all ship with Bun. A stack that previously required Node + Express + sqlite3 + ws + jest collapses into a single binary. That is not just convenience; it is a meaningful reduction in supply-chain surface area.
There are situations where Bun is the wrong call. If you are pinned to the AWS Lambda Node.js runtime, depend on a critical CommonJS-only internal library, or rely heavily on native add-ons such as node-canvas, Node 22 LTS is still the safer choice. Pick the tool that matches your constraints, not the one that benchmarks well on Hacker News.
Architecture: a dependency-light Claude API proxy
Let me set the scope. We are building a "thin proxy" that takes a prompt from the client, forwards it to Claude API, and streams the response straight back. But it has to satisfy a handful of real production requirements:
Per-request token usage and cost are persisted to SQLite
Concurrent requests from the same user are throttled with simple rate limiting
Anthropic-side errors are classified internally rather than leaked verbatim
Client disconnects mid-stream cancel the upstream Claude call so we stop billing
The only external dependency is @anthropic-ai/sdk. The HTTP server, SQLite, and WebSocket primitives are all Bun built-ins.
Client → Bun.serve (HTTP/WebSocket) → AnthropicProxy
↓
bun:sqlite (usage and history)
↓
Anthropic API (streaming)
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Engineers wondering whether to migrate their Node.js Claude API server to Bun can now make that decision with concrete benchmarks and a clear migration framework
✦You will be able to design and ship a low-latency Claude API service that uses Bun's built-in SQLite, WebSocket, and HTTP server to minimize external dependencies
✦You can avoid the Bun-specific pitfalls (Node compatibility edges, native modules, test runner differences, Linux distribution) that typically only show up after going to production
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Bun.serve gives you a much friendlier API than Node's http.createServer. Here is how I forward a Claude streaming response straight to the client:
// src/server.tsimport Anthropic from "@anthropic-ai/sdk";import { recordUsage } from "./usage";import { checkRateLimit, releaseRateLimit } from "./ratelimit";import { classifyError } from "./errors";const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });const server = Bun.serve({ port: 8787, // Bun supports async fetch natively async fetch(req: Request) { const url = new URL(req.url); if (url.pathname !== "/api/chat") { return new Response("Not Found", { status: 404 }); } const userId = req.headers.get("x-user-id") ?? "anonymous"; // Allow at most two concurrent requests per user const allowed = await checkRateLimit(userId); if (!allowed) { return new Response("Too Many Requests", { status: 429 }); } const { messages } = await req.json(); const stream = new ReadableStream({ async start(controller) { try { const response = await client.messages.stream({ model: "claude-opus-4-6", max_tokens: 1024, messages, }); for await (const event of response) { if (event.type === "content_block_delta") { const text = (event.delta as { text: string }).text; controller.enqueue(new TextEncoder().encode(text)); } } const final = await response.finalMessage(); recordUsage({ userId, inputTokens: final.usage.input_tokens, outputTokens: final.usage.output_tokens, createdAt: Date.now(), }); controller.close(); } catch (err) { const classified = classifyError(err); console.error("[chat-error]", classified); controller.error(classified); } finally { releaseRateLimit(userId); } }, }); return new Response(stream, { headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-cache, no-transform", }, }); },});console.log(`Listening on http://localhost:${server.port}`);
Note that messages.stream returns something you can iterate with for await directly. The Anthropic SDK supports the same shape on Node.js, but in my measurements the conversion to ReadableStream is meaningfully cheaper on Bun. For the same input I see Node taking 1.8 seconds and Bun finishing in around 1.65 seconds. That gap is small per request, but multiplies into real money once you are processing tens of thousands of requests per day.
bun:sqlite gives you better-sqlite3-class performance with no external dependency. To keep an honest tally of what you owe Anthropic, persist input_tokens and output_tokens per request.
// src/usage.tsimport { Database } from "bun:sqlite";const db = new Database("usage.db", { create: true });db.exec(` CREATE TABLE IF NOT EXISTS usage_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, input_tokens INTEGER NOT NULL, output_tokens INTEGER NOT NULL, created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_usage_user ON usage_log(user_id, created_at); PRAGMA journal_mode = WAL;`);const insertStmt = db.prepare(` INSERT INTO usage_log (user_id, input_tokens, output_tokens, created_at) VALUES ($userId, $inputTokens, $outputTokens, $createdAt)`);export interface UsageRecord { userId: string; inputTokens: number; outputTokens: number; createdAt: number;}export function recordUsage(rec: UsageRecord) { insertStmt.run({ $userId: rec.userId, $inputTokens: rec.inputTokens, $outputTokens: rec.outputTokens, $createdAt: rec.createdAt, });}export function getDailyCost(userId: string, date: Date): number { const start = new Date(date); start.setHours(0, 0, 0, 0); const end = new Date(start); end.setDate(end.getDate() + 1); const row = db .prepare( `SELECT COALESCE(SUM(input_tokens), 0) AS in_tok, COALESCE(SUM(output_tokens), 0) AS out_tok FROM usage_log WHERE user_id = $userId AND created_at >= $start AND created_at < $end` ) .get({ $userId: userId, $start: start.getTime(), $end: end.getTime(), }) as { in_tok: number; out_tok: number }; // Example pricing for Claude Opus 4.6 (USD) const inputCost = (row.in_tok / 1_000_000) * 15; const outputCost = (row.out_tok / 1_000_000) * 75; return inputCost + outputCost;}
The prepared statement runs synchronously, but SQLite is fast enough on writes that this is rarely a bottleneck. On my laptop I clocked 8,000 inserts per second. For services where the Claude response itself takes seconds, the write side is comfortably below the noise floor. Always enable WAL mode in production — PRAGMA journal_mode = WAL removes the writer-blocks-readers behavior that bites smaller services first.
Concurrency control: a minimum-viable rate limiter
For edge-deployed services there is rarely a justification to introduce Redis. Here is a deliberately minimal in-memory limiter that uses only Bun primitives.
// src/ratelimit.tsconst inflight = new Map<string, number>();const MAX_PARALLEL = 2;export async function checkRateLimit(userId: string): Promise<boolean> { const current = inflight.get(userId) ?? 0; if (current >= MAX_PARALLEL) { return false; } inflight.set(userId, current + 1); return true;}export function releaseRateLimit(userId: string) { const current = inflight.get(userId) ?? 0; if (current <= 1) { inflight.delete(userId); } else { inflight.set(userId, current - 1); }}
This is process-local. Once you scale beyond a single Bun process you will need either a SQLite-backed short-term table or a Redis client. For a one-developer service serving up to a million requests per month, a single Bun process tends to be perfectly adequate.
The most common bug here is forgetting to release the slot when the request errors. The finally block in server.ts above is the safety net.
Error classification: building dashboards that mean something
Logging Anthropic errors with a raw console.error is not enough to operate the service. Classifying by status and category makes it possible to immediately see "are we being rate limited", "are we paying for an outage upstream", or "is the client misconfigured".
Once you record the category you can wire SLO-friendly alerts: "page on the first auth_error", "page only when rate_limit exceeds 10 in 5 minutes", and so on. The category vocabulary is what makes those rules expressible.
Stopping the meter when the client hangs up
Claude API charges you for the tokens you ask for. If the client disconnects but the upstream call keeps streaming, you keep paying. Bun's Request.signal fires on client disconnect, so wire it through to the SDK's AbortController.
// In the fetch handler, before creating the streamconst ac = new AbortController();req.signal.addEventListener("abort", () => { console.log("[client-disconnect]", { userId }); ac.abort();});const response = await client.messages.stream( { model: "claude-opus-4-6", max_tokens: 1024, messages, }, { signal: ac.signal });
Now a closed tab or a refreshed page also stops the upstream call. In long-form generation use cases this can save real money — I have seen it shave low four-figure dollar amounts off a monthly bill.
Five Bun-specific traps to know about up front
Before you take any of this to production, here are the issues I have hit in real systems, ranked by how often they bit me.
Node compatibility edges. Bun's Node compatibility is impressively high but not 100%. Some crypto.createHash variants, low-level net operations, and worker_threads can behave subtly differently. The Anthropic SDK itself runs cleanly, but old transitive HTTP clients sometimes do not. Run bun pm trust --all after bun add @anthropic-ai/sdk so install scripts for native packages execute and surface compatibility gaps early.
Test runner differences.bun test aims for Jest API parity, but describe.skip.each and parts of snapshot matching diverge. Migrating an existing Jest suite verbatim can produce flaky failures. My recommendation: write new projects against bun test from the start, and use bun --bun jest in compatibility mode for migrations until you have time to port suites.
// tests/proxy.test.ts — native bun testimport { test, expect } from "bun:test";import { classifyError } from "../src/errors";import Anthropic from "@anthropic-ai/sdk";test("classifies 429 as rate_limit with retry", () => { const err = new Anthropic.APIError(429, undefined, "rate limited", undefined); const result = classifyError(err); expect(result.category).toBe("rate_limit"); expect(result.shouldRetry).toBe(true);});test("classifies 401 as auth_error without retry", () => { const err = new Anthropic.APIError(401, undefined, "unauthorized", undefined); const result = classifyError(err); expect(result.category).toBe("auth_error"); expect(result.shouldRetry).toBe(false);});
Linux binary size.bun build --compile produces a single binary, but it bundles the whole runtime, so expect 60–80 MB. That is over Lambda's 50 MB limit. The pragmatic answer is container images: Distroless plus the Bun binary lands around 100 MB, which is fine for Cloud Run, ECS, and Fly.
# DockerfileFROM oven/bun:1.2-alpine AS baseWORKDIR /appCOPY package.json bun.lockb ./RUN bun install --frozen-lockfileCOPY . .EXPOSE 8787CMD ["bun", "run", "src/server.ts"]
Memory model differences. Bun does not honor --max-old-space-size the way Node does. When you suspect a leak in a long-running Bun service, switch to bun --smol to dial memory pressure down for diagnosis. I had streaming responses leaving residual memory in Bun 1.1.x; that is fixed in 1.2.x.
PM2 / forever quirks. Some process managers fail to detect bun cleanly. I have settled on Docker --restart=always or systemd with Type=simple for VM deployments. If you are on Cloudflare or another platform with native Bun support, lean on the platform's own process management instead of bolting one on.
Migration strategy: how I roll Node services onto Bun
If you already have a Node Claude API service in production, swapping the runtime overnight is rightly scary. Here is the staged approach I have used across multiple projects.
Step one is to switch local development to Bun first. Change your package.json to bun src/server.ts --watch for scripts.dev. Production stays on Node, but every developer gets faster boots and instant TypeScript. This alone usually pays for the migration cost in a week.
Step two is moving CI tests to Bun. bun test is three to five times faster than Jest in my measurements. If suites fall over, fall back to bun --bun jest to triangulate. It is much cheaper to fail in CI than in production.
Step three is the runtime swap. Stand the Bun build up in staging for a week or two. Compare CPU, memory, p95 / p99 latency, and error rates side by side with the Node version using whatever you already have — Datadog, Cloudflare Analytics, an in-house dashboard, anything. "It feels faster" is not a strong enough signal to switch a runtime in production.
In my most recent migration, p95 latency dropped from 2.4s to 2.05s, cold start went from 220ms to 95ms, and idle memory shrank from 80 MB to 50 MB. CPU was a wash. Numbers like these are what you should be measuring against — use mine as a sanity check, not as a target.
Real-world cost numbers from a small production service
It helps to anchor the decision with actual numbers rather than vibes. One of my services — a single Claude proxy fronting a chat feature with about 15,000 daily active users — went through this migration last quarter. Here is what the side-by-side measurement looked like across one full week of real traffic.
On the Node.js 22 LTS version, average CPU sat at 38% across two e2-standard-2 instances on Cloud Run. Idle memory hovered around 80 MB per instance, and p95 end-to-end latency for streaming completion was 2.41 seconds. The container image, after multistage builds, was 142 MB.
After the Bun migration, the same workload ran comfortably on a single instance with CPU at 41%. Idle memory dropped to 51 MB, p95 latency moved to 2.05 seconds, and the container image dropped to 96 MB. The Cloud Run bill went down by roughly 38% the following month.
That last number is the one I want to be careful with. The Cloud Run cost dropped because we could run on one fewer instance, not because Bun is intrinsically cheaper to host. If your service is already comfortably within a single instance on Node, you should expect the cost delta to be small. The bigger value is usually developer time saved on TypeScript transpile, smaller images shipping faster from CI, and the cleaner dependency graph.
The lesson is simple: measure first. The "Bun is cheaper" or "Bun is faster" claims are only meaningful in the context of how your service was sized to begin with.
Where the Anthropic SDK behaves slightly differently on Bun
One subtle area I want to flag is how the Anthropic SDK interacts with Bun's HTTP client implementation. On Node.js, the SDK uses undici underneath. On Bun, it falls through to Bun's built-in fetch. They behave identically for normal request flows, but I have spotted three places where the differences matter.
The first is idle connection reuse. Bun's HTTP client is more aggressive about closing idle connections than undici is by default. Under low traffic this is barely visible, but under bursty traffic patterns I have seen Bun establish new TCP connections more often than Node would. The fix is to keep a small pool warm by issuing a lightweight Anthropic call (for example, a one-token /v1/messages/count_tokens request) on a 30-second timer in the background.
The second is header normalization. Bun's fetch lowercases all response headers, which matches the Fetch spec. Node's undici was historically inconsistent. If your code reads response.headers.get("Anthropic-Request-Id") (capitalized), it might silently return null on Bun. Always lowercase header names when reading them.
The third is Server-Sent Events parsing. The Anthropic SDK does its own parsing on top of the underlying HTTP body, so this rarely matters in practice. But if you write a custom streaming consumer that splits chunks on `
, be aware that Bun's Response.body.getReader()` returns chunks of slightly different sizes than Node does. Buffer the body until you see a complete event boundary; do not assume one chunk equals one event.
These are not bugs; they are different but valid implementations. Knowing where they diverge keeps you out of debugging spirals where a working Node test inexplicably flakes on Bun.
Production observability without bringing in a heavy stack
For solo developers and small teams, full Datadog or New Relic deployments are often overkill. Bun's introspection primitives, combined with structured logging, get you most of what you need.
Wrap your fetch handler with startSpan("chat_request") and call span.end({ userId, tokenUsage }) in the finally block. That alone gives you per-request latency, token usage, and a request ID you can trace through your platform's log search. When you outgrow this you can graduate to OpenTelemetry without changing the call sites — just swap the implementation of log and startSpan.
The point is that you can ship a respectable observability story on Bun without adding heavyweight dependencies on day one. Premature instrumentation has its own costs.
Hot reload and developer ergonomics
The other unsung win of Bun is the developer feedback loop. bun --hot src/server.ts does live module replacement without restarting the process, which means your in-memory rate limiter and SQLite connection survive the reload. With Node and nodemon, every file save costs you a fresh process boot — typically two to three seconds — and forces you to re-establish any in-memory state.
For a Claude API service that's especially nice because you can keep an open streaming session in a browser tab while editing the prompt logic. The change re-applies, the next request uses the new code, and you don't lose your conversation context. I shaved easily 30 minutes a day off iteration time after switching to --hot.
A note on bun build: it produces a single file but does not bundle the runtime. The output is intended for Bun, not Node, which is precisely why the file size stays small. If you want a single binary for distribution, use bun build --compile, accept the 60–80 MB payload, and ship it inside a container.
When to reach for worker threads
For most Claude API proxies you do not need worker threads. The bulk of the latency is upstream, not local CPU. But there are two scenarios where they earn their keep on Bun.
The first is post-processing of streamed output. If you are running a regex-heavy moderation pass, computing a summary embedding, or doing structured extraction on the full response, you want that work off the request thread. Bun supports the standard worker_threads API, so the patterns you know from Node carry over.
The second is batched prompt formatting. If your service builds long prompts by joining many records (for example, a "summarize these 100 customer emails" feature), the JSON parsing and string concatenation can compete with the streaming loop for CPU. Pushing that work into a worker keeps your event loop responsive and your stream chunks moving.
A small caveat: cross-thread bun:sqlite connections do not share state. Each worker should open its own database handle, and you should rely on SQLite's own concurrency model (WAL mode helps here too) rather than passing the handle around.
Edge deployment options compared
If you are choosing where to host the Bun service, here is how the main options stack up in 2026 from a Claude API perspective.
Cloud Run on GCP: most straightforward. Image pulls cold-start in 200–400 ms, scaling-to-zero saves money, and request-based pricing matches Anthropic's billing model nicely.
Fly.io: closest to "VM with a friendly dashboard". Best when you want long-lived processes (always-on rate-limit state) and predictable pricing.
AWS App Runner: works but has rougher edges around cold start and image cache eviction. Reasonable if you are already in AWS.
Cloudflare Workers: not a fit for raw Bun servers. Use Workers if you want the Cloudflare runtime; do not try to run Bun.serve inside a Worker.
Self-hosted VM with systemd: still my preference for personal projects where I want predictable cost and full control.
The right choice usually depends on what else you are running, not on what is "best for Bun" in isolation. If you are already on Cloud Run, stay on Cloud Run.
Pre-launch checklist
Before pushing a Bun-based Claude API service into production, walk through this list:
SQLite is in WAL mode, and you have load-tested that writes do not stall under realistic concurrency
Request.signal actually fires on client disconnect (test it: curl -N, then Ctrl+C, then read the logs)
bun pm trust --all has been run; native install scripts have completed
The Anthropic API key is injected via environment variables, not baked into the image
Logs are structured JSON so Cloud Run, Cloudflare Logs, or Datadog can query them
The service has run for 24 hours straight without --smol and shown no memory creep
Staging has been compared head-to-head with the Node version for a week of real traffic
Retry logic is in place for rate_limit and overloaded, with exponential backoff
Pick the smallest service you currently run on Node, change one line in package.json to invoke bun src/server.ts, and try a normal day of development on it. In most projects that is the entire local migration. From there, move CI to bun test, then run a Bun build in staging next to the Node build for a week before flipping production.
You do not need to commit to "all Bun, everywhere". Even running Bun only in development, only in CI, or only on a single service tightens dependencies, shrinks images, and improves boot time. The right reason to adopt Bun is not "it is fast"; it is "the resulting system is simpler". That framing gives you an honest answer to whether the migration is worth your time.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.