Build a Bun + WebSocket Voice Agent with Bun.serve Patterns
By Sagar Shankaran, Founder of CallSphere
Bun's native Bun.serve WebSocket API outperforms Node ws by 4-7x on idle sockets. Build a full Realtime voice bridge in TypeScript with binary frames and pub/sub.
Key takeaways
TL;DR — Bun's WebSocket isn't a library — it's part of
Bun.serve. Nowsinstall, native pub/sub, and 4-7x throughput vs Node on idle voice sockets. Perfect for a Realtime bridge.
What you'll build
A single-file Bun server that upgrades browser clients to WebSocket, opens a paired client to OpenAI Realtime via the standard WebSocket global, and pumps frames between them. We add per-call session IDs, a basic rate limit, and a /healthz route.
Prerequisites
- Bun 1.1+ installed (
curl -fsSL https://bun.sh/install | bash). OPENAI_API_KEYset in env.- A static frontend that connects to
ws://localhost:8080/voice. - Familiarity with TypeScript and the WebSocket API.
bun init-ed project.
Architecture
flowchart LR
B[Browser] -- ws --> S[Bun.serve]
S -- ws --> O[OpenAI Realtime]
S -- pubsub --> S2[(other Bun nodes)]
Step 1 — Bun.serve with WebSocket upgrade
```typescript type SessionData = { id: string; oai: WebSocket };
const server = Bun.serve<SessionData, undefined>({ port: 8080, fetch(req, srv) { const url = new URL(req.url); if (url.pathname === "/healthz") return new Response("ok"); if (url.pathname !== "/voice") return new Response("nf", { status: 404 });
const id = crypto.randomUUID();
if (srv.upgrade(req, { data: { id, oai: undefined as any } })) return;
return new Response("Upgrade failed", { status: 400 });
}, websocket: { open(ws) { handleOpen(ws); }, message(ws, msg) { handleMessage(ws, msg); }, close(ws) { ws.data.oai?.close(); }, }, }); console.log("listening", server.port); ```
Step 2 — Open the OpenAI socket on open
Bun's global WebSocket supports protocols and headers — perfect for Realtime auth.
Hear it before you finish reading
Talk to a live CallSphere AI voice agent in your browser — 60 seconds, no signup.
```typescript function handleOpen(ws: any) { const oai = new WebSocket( "wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2025-06-03", { headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, "OpenAI-Beta": "realtime=v1", }, } as any ); ws.data.oai = oai;
oai.addEventListener("open", () => { oai.send(JSON.stringify({ type: "session.update", session: { instructions: "You are CallSphere's Bun-powered agent.", voice: "alloy", input_audio_format: "pcm16", output_audio_format: "pcm16", turn_detection: { type: "server_vad" } } })); ws.subscribe(`call:${ws.data.id}`); });
oai.addEventListener("message", (e) => { server.publish(`call:${ws.data.id}`, e.data as string); }); } ```
Step 3 — Forward client → OpenAI
```typescript function handleMessage(ws: any, raw: string | Buffer) { const text = typeof raw === "string" ? raw: raw.toString("utf8"); if (ws.data.oai?.readyState === 1) ws.data.oai.send(text); } ```
Step 4 — Native pub/sub for fan-out
Bun's subscribe / publish is built into the WebSocket — no Redis needed for small fleets:
```typescript // In another route, push system messages to all connected sessions: server.publish("call:" + sessionId, JSON.stringify({ type: "conversation.item.create", item: { type: "message", role: "system", content: [{ type: "input_text", text: "Tool call done" }]} })); ```
Step 5 — Binary audio framing
Some clients send raw binary; Realtime expects base64 strings. Convert in the message handler:
Still reading? Stop comparing — try CallSphere live.
CallSphere ships complete AI voice agents per industry — 14 tools for healthcare, 10 agents for real estate, 4 specialists for salons. See how it actually handles a call before you book a demo.
```typescript if (raw instanceof Buffer || raw instanceof Uint8Array) { ws.data.oai.send(JSON.stringify({ type: "input_audio_buffer.append", audio: Buffer.from(raw).toString("base64"), })); } ```
Step 6 — Add a per-IP rate limit
```typescript const buckets = new Map<string, { n: number; reset: number }>(); function allow(ip: string, max = 5, windowMs = 60_000) { const now = Date.now(); const b = buckets.get(ip) ?? { n: 0, reset: now + windowMs }; if (now > b.reset) { b.n = 0; b.reset = now + windowMs; } b.n++; buckets.set(ip, b); return b.n <= max; } ```
Common pitfalls
- Using
wsmodule on Bun — it works but you lose 4x performance vs native. - Mixing Buffer and string in one handler — branch explicitly.
- No
closecleanup — leaks OpenAI sockets and your bill explodes. - Not setting
maxPayloadLength— large audio frames get dropped.
How CallSphere does this in production
CallSphere's lightweight tenant-isolation proxy is a Bun service in front of the FastAPI:8084 voice cluster. It validates JWTs, applies plan-tier rate limits ($49/$99/$149 — see pricing), and routes to the right vertical agent. 6 verticals, 90+ tools, HIPAA.
FAQ
Is Bun production-ready? Yes — 1.0 shipped Sep 2023, stable for HTTP & WS workloads.
Can I deploy on Vercel? Vercel doesn't run Bun runtimes; use Fly.io, Railway, or your own host.
What about TypeScript? Bun runs TS natively; no tsx needed.
Hot reload? bun --watch run server.ts.
Cluster mode? Bun.serve({ reusePort: true }) and run N copies behind a single port.
Sources

Written by
Sagar Shankaran· Founder, CallSphere
LinkedInSagar Shankaran is the founder of CallSphere, where he builds production AI voice and chat agents deployed across healthcare, hospitality, real estate, and home services. He writes about agentic AI, LLM engineering, and shipping voice agents that handle real calls in production.
Try CallSphere AI Voice Agents
See how AI voice agents work for your industry. Live demo available -- no signup required.