schedule 12 min read

Hono + Bun for AI Platforms: 6 Production Patterns Worth Stealing

Build an AI platform on Hono + Bun: 100+ services, SSE streaming, sandboxed code execution, real-time WebSocket. 6 production patterns from a real codebase.

translate
Available in:

AI platforms share a common set of infrastructure demands: real-time streaming, file-heavy workloads, schema-validated agent protocols, multi-tenant auth, and the need to move fast without sacrificing type safety. Most JavaScript server frameworks were designed before streaming LLM responses and edge deployment were standard requirements.

At QuotyAI we build a deterministic sales AI platform — the kind that generates TypeScript pricing functions from natural language, executes them in sandboxes, and streams results to agent UIs in real time. Our backend runs 100+ services through tsyringe, serves SSE streams, handles WebSocket events across multiple messaging channels, and does it all in one Bun process.

Hono + Bun is the combination that makes this possible. Hono ships in under 14KB with zero dependencies, provides compile-time type inference across middleware chains, and runs on Bun, Deno, Cloudflare Workers, and Node.js without code changes. Bun contributes fast native I/O, built-in SQLite and PostgreSQL clients, and increasingly OS-level APIs that previously required separate services.

These are the six patterns that emerged from six months of production use.

“Hono forces you to get types right. Express lets you ship bugs to production.”

Heads up on versions: This article covers Hono ^4.12.27 and Bun latest (June 2026). The code patterns use Socket.IO 4.8.3 with @socket.io/bun-engine 0.1.1 — the Bun-native adapter is still young, which we cover in What Didn’t Work. All examples come from the QuotyAI monorepo.


⚖️ How the Frameworks Compare for AI Workloads

Requirement Express FastAPI NestJS Hono
Bundle size ~200KB+ Python runtime overhead ~40KB + deps 14KB
Runtime portability Node.js only Python only Node.js Bun, Deno, CF Workers, Node.js
Middleware type safety None Pydantic (runtime) Decorator-based Compile-time inference
Edge / Worker support Adapter needed None Adapter needed Native
Streaming (SSE) Manual implementation Async generators Manual implementation Built-in streamSSE
Cold start (serverless) 50–100ms 200–500ms 80–150ms 5–10ms

Express treats types as optional, which works for CRUD APIs but breaks down for AI platforms where request validation, business logic, agent execution, and response serialization must agree on the same types. One mismatch corrupts the output.

FastAPI provides strong type guarantees through Pydantic but locks you into Python — problematic when you need edge deployment, native image processing, or WebSocket multiplexing from the same process.

NestJS adds structure at the cost of abstraction overhead. Its decorator-based type system doesn’t extend to middleware variables or cross-cutting request context.

Hono’s type inference is unique among JS frameworks: c.get() and c.set() are checked at compile time, not runtime. Define typed Variables on the app and every middleware and handler downstream must match, or TypeScript rejects the build.

💡 Unique Insight: Changing tenantId from string to ObjectId in our type definitions flagged 14 route handlers that needed updating — at compile time. In Express, those become runtime errors discovered by customers. This single feature eliminated an entire category of bugs from our development cycle.


🏗️ Architecture: One Process, 30 Route Files

A single Bun process serves both HTTP and WebSocket through URL-based routing at the transport layer:

import { Server as SocketIOBunEngine } from '@socket.io/bun-engine';
import { Server as SocketIOServer } from 'socket.io';
import { app } from './hono-routes.js';

const io = new SocketIOServer();
const engine = new SocketIOBunEngine();
io.bind(engine);

const realtimeNamespace = io.of('/realtime');
realtimeNamespace.use(async (socket, next) => {
  const token = socket.handshake.auth?.token;
  const user = await authService.verifyToken(token);
  (socket as any).user = user;
  next();
});

export default {
  port: 3000,
  idleTimeout: 30,
  fetch(req, server) {
    const url = new URL(req.url);
    if (url.pathname === '/socket.io/') return engine.handleRequest(req, server);
    return app.fetch(req, server);
  },
  websocket: engine.handler().websocket,
};

One process. Hono handles HTTP. Socket.IO handles WebSocket. No proxy, no second port, no adapter layer between Bun and the WebSocket engine.

The Hono router mounts 30 sub-routers, each with its own middleware stack:

const app = new Hono<{ Variables: AppVariables }>();
app.use('*', cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') }));
app.use('*', rateLimitMiddleware({ windowMs: 60_000, max: 100 }));
app.use('*', commonLoggerContextMiddleware);

app.route('/agent-protocol', agentProtocolApp);
app.route('/chat', chatApp);
app.route('/webhooks', webhooksApp);
// ... 27 more route groups

The /agent-protocol routes carry their own DI middleware and auth checking. The /webhooks routes have HMAC signature verification. No global service locator, no shared state between domains.

Architecture diagram showing a single Bun process serving both Hono HTTP routes and Socket.IO WebSocket connections on port 3000, with URL-based routing dispatching to either handler at the transport layer

📦 Pattern 1: Typed Variables Eliminate Context Bugs

This is the feature that makes Hono fundamentally different from Express for multi-service AI platforms.

Define shared request context once:

export type AppVariables = {
  db: Database;
  container: DependencyContainer;
  user?: AuthUser;
  tenantId?: ObjectId;
};

Every c.get() and c.set() is checked at compile time across every middleware and handler. Sub-routers extend the base type with their own dependencies:

type AgentProtocolVariables = {
  agentProtocolService: AgentProtocolService;
  user?: { id: string };
  tenantId: ObjectId;
  authService: AuthService;
};

Each sub-app declares exactly what it needs. Access c.get('somethingUndefined') and TypeScript rejects it before the first test runs.

See the Hono middleware docs for the full API reference on typed variables.

💡 Unique Insight: We keep our shared AppVariables type in a single file imported by every route module. When a new dependency needs per-request context, we add it in one place and the compiler propagates the constraint across all 30 route groups. No runtime discovery. No undefined surprises at 3am.


🔄 Pattern 2: SSE Streaming for Agent Communication

AI agents communicate in streams. Token-by-token LLM output, tool-call sequences, long-form reasoning — all map naturally to Server-Sent Events. Hono’s streamSSE helper handles the lifecycle with filtered event sinks and heartbeat management:

import { streamSSE } from 'hono/streaming';

app.post('/threads/:threadId/stream/events', async (c) => {
  const params = await c.req.json();
  const service = c.get('agentProtocolService');

  return streamSSE(c, async (stream) => {
    const sinkId = crypto.randomUUID();
    let detached = false;

    const detachSink = () => {
      if (detached) return;
      detached = true;
      service.detachEventSink(threadId, sinkId);
    };

    const send = async (event: any) => {
      if (detached || stream.aborted) return;
      try {
        await stream.writeSSE({
          id: event.event_id,
          event: event.method,
          data: JSON.stringify(event),
        });
      } catch {
        detachSink();
      }
    };

    await service.attachFilteredEventSink(threadId, {
      id: sinkId,
      filter: {
        channels: new Set(params.channels ?? ['messages', 'tools', 'lifecycle']),
        namespaces: params.namespaces,
      },
      send,
    });

    stream.onAbort(detachSink);

    const heartbeatTimer = setInterval(async () => {
      if (stream.aborted) return clearInterval(heartbeatTimer);
      await stream.writeSSE({ event: 'ping', data: '{"type":"heartbeat"}' });
    }, 30_000);

    await new Promise<void>((resolve) => {
      stream.onAbort(() => { detachSink(); resolve(); });
    });
  });
});

The 30-second heartbeat keeps connections alive through proxies and load balancers. The detachSink pattern prevents memory leaks when clients disconnect without a clean close — which happens often on mobile networks.

Bun’s HTTP server handles streaming bodies with zero configuration. No adapter, no special middleware.

💡 Unique Insight: The detachSink guard pattern was added after a production incident where a mobile client navigated away mid-stream. Without it, every aborted connection leaked an event sink subscription. The stream.onAbort callback fires reliably on Bun, but the async gap between stream.aborted checks and writeSSE calls means you need both the flag and the callback for full coverage.

Sequence diagram showing the SSE streaming lifecycle: client connects, Hono streamSSE creates a filtered event sink with heartbeat timer, agent events flow through the sink to writeSSE, and on client disconnect the detachSink pattern cleans up the subscription and interval

Refer to the Hono streaming docs for the streamSSE API reference.


🏖️ Pattern 3: Sandboxed Code Execution for AI-Generated Functions

AI platforms that generate and execute code need sandboxing at the runtime level. The Node.js vm module provides this with a strict allowlist and hard timeout:

import * as vm from 'vm';

const SANDBOX_TIMEOUT_MS = 5000;

function executeCode<T>(code: string, input: unknown, functionName: string): T {
  const sandbox = {
    console: { log: (...args) => logger.debug(...args) },
    Date, Math, Array, Object, String, Number, Boolean, RegExp, JSON,
    Error, TypeError, ReferenceError, RangeError,
    parseInt, parseFloat, isNaN, isFinite,
  };

  const context = vm.createContext(sandbox, {
    name: 'Sandbox',
    codeGeneration: { strings: false, wasm: false },
  });

  const script = new vm.Script(
    `${code}\n__result__ = ${functionName};`,
    { filename: `sandbox-${functionName}.js` }
  );

  script.runInContext(context, { timeout: SANDBOX_TIMEOUT_MS });

  const runner = (sandbox as any).__result__;
  return Array.isArray(input) ? runner(...input) : runner(input);
}

Key constraints:

  • Allowlist only: Date, Math, Array, Object, JSON, basic error constructors, parseInt, parseFloat. No require, no import, no eval, no fetch, no process.
  • No dynamic code generation: codeGeneration: { strings: false, wasm: false } blocks eval-like patterns inside the sandbox.
  • Hard timeout: 5 seconds kills infinite loops. AI-generated code produces them more often than you’d expect.

When execution fails, save the script for debugging:

async function saveFailedScript(code: string, error: Error) {
  const filename = `failed-script-${Date.now()}.js`;
  await fs.mkdir('./troubleshooting-scripts', { recursive: true });
  await fs.writeFile(
    `./troubleshooting-scripts/${filename}`,
    `// Error: ${error.message}\n\n${code}`
  );
}

💡 Unique Insight: Three production edge cases caught this way: infinite loops from malformed recursion in generated code, NaN propagation from missing null checks, and a timeout from a generated function using while(true). Without the saved scripts, each would have been a multi-hour debugging session. The troubleshooting directory is now the first place we look when a sandbox execution fails.

Pipeline diagram showing: user input → AI generates TypeScript functions → code enters the vm.createContext sandbox with strict allowlist (no require, no fetch, no process) → 5-second timeout → result or error with troubleshooting script saved to disk

⚡ Pattern 4: Database Change Streams to WebSocket

Real-time event pipelines for AI platforms can be built without a message broker by chaining database change streams to WebSocket:

MongoDB ChangeStream (6 collections)
  → eventBus.emit() (typed EventEmitter, 27 event types)
    → Socket.IO adapter (listens to all 27 events)
      → Room emissions (tenant:*, conversation:*)

When a new message arrives through any channel (Telegram, Facebook, web form), the Change Stream detects the document, the event bus emits a typed event, and the Socket.IO adapter broadcasts to subscribers:

export function setupSocketIOAdapters(io: SocketIOServer) {
  eventBus.on('conversation:message:created', (event) => {
    io.to(`conversation:${event.conversationId}`)
      .emit('conversation:message:created', event);
  });

  eventBus.on('agent:status:changed', (event) => {
    io.to(`tenant:${event.tenantId}`)
      .emit('agent:status:changed', event);
  });
  // ... 25 more event types
}

No message broker, no Redis pub/sub. One process handles the full pipeline. When horizontal scaling becomes necessary, @socket.io/redis-adapter drops in without changing the event mapping layer — the adapter sits between Socket.IO and Redis, not between your code and Socket.IO.

See the MongoDB Change Streams docs for setup requirements (replica set, oplog access).


🛡️ Pattern 5: RFC 9457 Problem Details for Structured Errors

Standardised error responses save hours of debugging. The hono-problem-details package maps framework errors to RFC 9457 format:

app.onError(
  problemDetailsHandler({
    autoInstance: true,
    mapError: (error) => {
      if (error instanceof HTTPException && error.cause instanceof ZodError) {
        return {
          status: 422,
          title: 'Validation Error',
          detail: 'Request validation failed',
          extensions: {
            errors: error.cause.issues.map((issue) => ({
              field: issue.path.join('.'),
              message: issue.message,
              code: issue.code,
            })),
          },
        };
      }

      if (error instanceof LlmConfigError) {
        return { status: 404, title: 'LLM Configuration Not Found', detail: error.message };
      }

      if (error instanceof TenantError) {
        return { status: 409, title: error.message, detail: error.detail };
      }
    },
    localize: (pd, c) => {
      const correlationId = c.req.header('x-correlation-id');
      return { ...pd, correlationId, tenantId: c.get('tenantId')?.toString() };
    },
  }),
);

Every error response includes a correlation ID, tenant context, and structured validation details. When a client reports “the API returned an error,” you trace it to the exact request, tenant, and validation failure.

💡 Unique Insight: The localize callback in problemDetailsHandler is the hook that solved our “which tenant broke?” problem. Before adding correlation IDs to every error response, debugging a multi-tenant incident meant grepping logs by timestamp range. Now every error carries its tenant context natively — including validation errors that hit before the tenant middleware runs (using the x-tenant-id header directly from the request).

See the hono-problem-details docs for the full API.


🧩 Pattern 6: Dependency Injection by Dependency Level

When you register 100+ services, manual ordering becomes error-prone. Organising registrations by dependency level prevents circular resolution and makes the dependency graph explicit:

export function registerServices(container: DependencyContainer) {
  // Level 0 — no dependencies
  container.registerSingleton(EncryptionService);
  container.registerSingleton(DynamicRunnerService);

  // Level 1 — depends on Level 0
  container.registerSingleton(TenantStorage);
  container.registerSingleton(ApiKeyStorage);

  // Level 2 — depends on Levels 0–1
  container.registerSingleton(TenantService);
  container.registerSingleton(ApiKeyService);

  // ... through Level 7
}

Each route file resolves its own services through middleware, not a global locator:

const agentProtocolMiddleware = async (c: Context, next: Next) => {
  const service = container.resolve(AgentProtocolService);
  const authService = container.resolve(AuthService);
  c.set('agentProtocolService', service);
  c.set('authService', authService);
  await next();
};

No import-time side effects. Each middleware declares its dependencies explicitly. The DI container resolves them once at startup.

💡 Unique Insight: We use tsyringe with 8 dependency levels. Level 0 is pure utilities (encryption, sandbox runner). Level 7 is cross-cutting concerns (analytics, observability). The level comment at each registration makes circular dependencies immediately visible during code review — you can see at a glance whether a Level 2 service accidentally imports a Level 5 dependency.


⚠️ What Didn’t Work

Socket.IO on Bun needs @socket.io/bun-engine. The standard Socket.IO server assumes Node.js http.Server. The Bun-native adapter is at version 0.1.1 — it works, but expect rough edges around timeouts and connection handling. Test your reconnect logic thoroughly.

Some npm packages assume Node.js internals. Two issues in production: one package used process.nextTick in a way Bun handles differently, another expected fs.watch behaviour Bun doesn’t fully replicate. Both were fixable within a day, but worth checking your dependency tree before committing to Bun.

Hono’s type inference is strict by design. Change a middleware’s c.set() types and every downstream handler must match. This is the feature that prevents runtime bugs, but it means refactoring middleware requires touching more files than Express. Trade-off is worth it for correctness.

No production-ready image processing. Bun.Image exists but for serious workloads you’ll still want sharp or a dedicated service. The buffer-based approach works for light usage.


📊 Performance Numbers

Measured in production, not benchmarks:

Metric Value
Sandboxed function execution 24ms
API response (with LLM) 1–2s
Single document load (MongoDB) 50ms
SSE heartbeat interval 30s
Sandbox timeout 5,000ms
Rate limit default 100 req/60s

The 24ms sandbox execution time matters because it separates deterministic business logic from LLM latency. Users get fast, consistent results for anything that doesn’t need an AI call. For a deeper look at the deployment costs behind these numbers, see How I Run a Production AI Startup for $30/Month.


📋 The Stack

Layer Technology Version
Runtime Bun latest
HTTP framework Hono ^4.12.27
WebSocket Socket.IO + @socket.io/bun-engine ^4.8.3 / ^0.1.1
DI container tsyringe ^4.10.0
Validation Zod 4.3.6
Database MongoDB native driver ^7.3.0
AI orchestration LangChain + LangGraph ^1.2.1 / ^1.4.6
Logging LogTape + OpenTelemetry ^2.1.5

📋 When to Use This Stack

Use Hono + Bun when:

  • You need compile-time type safety across middleware chains
  • You want one process for HTTP, WebSocket, and background work
  • You’re deploying to both edge and traditional servers from the same code
  • Your team writes TypeScript and wants maximum compiler enforcement

Skip it when:

  • You depend on npm packages that assume Node.js internals (audit your dependencies first)
  • You need horizontal scaling on day one (plan the @socket.io/redis-adapter integration early)
  • Your team needs extensive battle-tested documentation (Hono’s docs are good but not Express-level)

For the original decision-making process behind choosing these tools, see Why I Picked Bun and Hono.


🗺️ What’s Next

Four improvements on the roadmap:

  • Per-tenant rate limiting. AI platforms must prevent prompt-cost abuse. Hono’s middleware model makes this composable — key the limiter by tenant ID, not just IP.
  • Streaming timeout handling. When an upstream LLM stalls mid-stream, the SSE connection hangs. The streamSSE handler needs backpressure-aware timeout logic.
  • Background workers. Offloading embedding generation and batch evaluation to Bun.Worker threads keeps the main request loop responsive.
  • Cost tracking middleware. Token-level accounting per request, surfaced via response hooks, gives tenants visibility into what their AI agents cost.

Each feature lands in the same Hono + Bun codebase. No framework migration. No rewrite.

That’s the point.


TL;DR

Category Pattern
Framework choice Hono over Express, FastAPI, NestJS for AI workloads
Server architecture Single Bun process, Hono HTTP + Socket.IO WebSocket on one port
Type safety Typed AppVariables — compile-time enforcement across all middleware
Agent streaming streamSSE with filtered event sinks, heartbeat, and detach-on-abort
Code sandboxing vm.createContext() with strict allowlist, codeGeneration: false, 5s timeout
Real-time events MongoDB ChangeStream → typed EventBus → Socket.IO rooms
Error handling RFC 9457 Problem Details with correlation IDs and tenant context
DI tsyringe with dependency levels to prevent circular resolution
Streaming SSE for unidirectional agent events, WebSocket for bidirectional UI interactions


Frequently Asked Questions

Why choose Hono over Express for AI platforms? Hono ships in under 14KB with zero dependencies, provides compile-time TypeScript type inference for middleware variables, and runs on Bun, Deno, Cloudflare Workers, and Node.js without code changes. Express has middleware type safety as an afterthought and needs adapters for edge runtimes.

Is Bun production-ready for AI backend services? Bun handles production workloads including HTTP serving, WebSocket upgrades, MongoDB connections, and sandboxed code execution. The main gap is npm ecosystem maturity — some packages assume Node.js-specific internals. The Bun-native Socket.IO engine is at v0.1.1, so expect some rough edges.

How do you run AI-generated code safely in production? Use Node.js vm.createContext() with a strict allowlist of globals (Date, Math, Array, Object, JSON), code generation disabled, and a 5-second timeout. The sandbox has no filesystem, network, or process access. Failed scripts get saved to disk with error context for debugging.

What is the difference between SSE and WebSocket for agent communication? SSE is unidirectional server-to-client streaming — ideal for LLM token output and agent event streams. WebSocket is bidirectional — needed for typing indicators, real-time UI updates, and multi-agent orchestration where clients push events back. Hono supports both: streamSSE for SSE, and Socket.IO on the same Bun process for WebSocket.

How do you handle multi-tenant context in Hono middleware? Define typed AppVariables on the Hono app with tenantId: ObjectId. Auth middleware extracts the tenant from the JWT or header and c.set('tenantId', ...). Every downstream handler accesses it via c.get('tenantId') — checked at compile time, no runtime lookups.


Tags: hono bun typescript ai-platform backend-architecture

Found this useful? Share it.

Related articles

Thanks for reading!
Read more articles