Skip to content
AI Engineering
AI Engineering11 min read0 views

Build a Bun + WebSocket Voice Agent with Bun.serve Patterns

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.

TL;DR — Bun's WebSocket isn't a library — it's part of Bun.serve. No ws install, 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

  1. Bun 1.1+ installed (curl -fsSL https://bun.sh/install | bash).
  2. OPENAI_API_KEY set in env.
  3. A static frontend that connects to ws://localhost:8080/voice.
  4. Familiarity with TypeScript and the WebSocket API.
  5. 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.

Try Live Demo →

```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 ws module on Bun — it works but you lose 4x performance vs native.
  • Mixing Buffer and string in one handler — branch explicitly.
  • No close cleanup — 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 ($149/$499/$1499 — see pricing), and routes to the right vertical agent. 6 verticals, 90+ tools, HIPAA + SOC 2.

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

Share

Try CallSphere AI Voice Agents

See how AI voice agents work for your industry. Live demo available -- no signup required.