โ† Back to DevBytes

State Management in Bun: Patterns and Libraries

Introduction to State Management in Bun

Bun has rapidly emerged as a powerful JavaScript runtime, bundler, and package manager, offering developers a fast and modern alternative to Node.js. While Bun excels at performance and developer experience, building robust applications still requires thoughtful handling of application state. State management in Bun refers to the techniques and patterns used to store, share, and mutate data across the lifecycle of an application, whether it is a web server, a CLI tool, or a real-time service.

Unlike frontend frameworks where state management libraries like Redux or Zustand dominate, server-side state management in Bun involves a different set of considerations. You must account for concurrency, memory limits, persistence, and the inherently stateless nature of HTTP requests. This tutorial explores practical patterns and libraries for managing state effectively in Bun applications.

Why State Management Matters in Bun

State management is the backbone of any application that needs to remember things. In a Bun server context, state can include:

Without a coherent state management strategy, applications become difficult to reason about, prone to race conditions, and hard to scale. Bun's single-threaded event loop simplifies some concurrency concerns compared to multi-threaded environments, but you still need deliberate patterns to keep your code maintainable.

Pattern 1: In-Memory State with Modules

The simplest form of state management in Bun leverages JavaScript's module system. Since ES modules are singletons within a process, exporting a mutable object or class instance creates shared state accessible across your application.

Basic In-Memory Store

Here is a minimal example of an in-memory store that can be imported anywhere in your Bun application:

// store.ts
interface User {
  id: string;
  name: string;
  lastSeen: number;
}

class UserStore {
  private users = new Map<string, User>();

  addUser(user: User): void {
    this.users.set(user.id, user);
  }

  getUser(id: string): User | undefined {
    return this.users.get(id);
  }

  touchUser(id: string): void {
    const user = this.users.get(id);
    if (user) {
      user.lastSeen = Date.now();
    }
  }

  removeStaleUsers(maxAgeMs: number): number {
    const now = Date.now();
    let removed = 0;
    for (const [id, user] of this.users) {
      if (now - user.lastSeen > maxAgeMs) {
        this.users.delete(id);
        removed++;
      }
    }
    return removed;
  }

  size(): number {
    return this.users.size;
  }
}

export const userStore = new UserStore();

You can then use this store in a Bun server:

// server.ts
import { userStore } from "./store";

const server = Bun.serve({
  port: 3000,
  async fetch(req) {
    const url = new URL(req.url);

    if (url.pathname === "/login") {
      const body = await req.json();
      const user = {
        id: crypto.randomUUID(),
        name: body.name,
        lastSeen: Date.now(),
      };
      userStore.addUser(user);
      return Response.json({ token: user.id });
    }

    if (url.pathname === "/profile") {
      const token = req.headers.get("Authorization");
      if (!token) {
        return new Response("Unauthorized", { status: 401 });
      }
      const user = userStore.getUser(token);
      if (!user) {
        return new Response("Not found", { status: 404 });
      }
      userStore.touchUser(token);
      return Response.json({ name: user.name });
    }

    return new Response("Not Found", { status: 404 });
  },
});

console.log(`Server running on http://localhost:${server.port}`);

// Periodic cleanup of stale users
setInterval(() => {
  const removed = userStore.removeStaleUsers(5 * 60 * 1000);
  if (removed > 0) {
    console.log(`Removed ${removed} stale users`);
  }
}, 60 * 1000);

This pattern is straightforward and works well for single-process applications. However, it has limitations: state is lost on restart, and it does not scale across multiple processes or machines.

Pattern 2: Event-Driven State with EventEmitter

For applications where state changes need to trigger side effects, combining an in-memory store with Bun's built-in EventEmitter creates a reactive architecture. This is particularly useful for real-time features like chat applications or live dashboards.

// eventStore.ts
import { EventEmitter } from "events";

interface GameState {
  roomId: string;
  players: string[];
  status: "waiting" | "active" | "finished";
}

class GameStore extends EventEmitter {
  private rooms = new Map<string, GameState>();

  createRoom(roomId: string): GameState {
    const state: GameState = {
      roomId,
      players: [],
      status: "waiting",
    };
    this.rooms.set(roomId, state);
    this.emit("room:created", state);
    return state;
  }

  joinRoom(roomId: string, playerId: string): boolean {
    const room = this.rooms.get(roomId);
    if (!room || room.status !== "waiting" || room.players.length >= 4) {
      return false;
    }
    room.players.push(playerId);
    this.emit("player:joined", { roomId, playerId, playerCount: room.players.length });

    if (room.players.length === 4) {
      room.status = "active";
      this.emit("room:started", room);
    }
    return true;
  }

  getRoom(roomId: string): GameState | undefined {
    return this.rooms.get(roomId);
  }
}

export const gameStore = new GameStore();

You can then subscribe to these events in your server logic:

// server.ts
import { gameStore } from "./eventStore";

const sockets = new Map<string, Set<WebSocket>>();

gameStore.on("player:joined", ({ roomId, playerCount }) => {
  const roomSockets = sockets.get(roomId);
  if (roomSockets) {
    for (const ws of roomSockets) {
      ws.send(JSON.stringify({
        type: "player_joined",
        playerCount,
      }));
    }
  }
});

gameStore.on("room:started", (room) => {
  const roomSockets = sockets.get(room.roomId);
  if (roomSockets) {
    for (const ws of roomSockets) {
      ws.send(JSON.stringify({ type: "game_started" }));
    }
  }
});

const server = Bun.serve({
  port: 3000,
  fetch(req, server) {
    const url = new URL(req.url);
    if (url.pathname === "/ws") {
      const roomId = url.searchParams.get("room") || "default";
      if (server.upgrade(req, { data: { roomId } })) {
        return;
      }
    }
    return new Response("WebSocket endpoint at /ws?room=ID", { status: 200 });
  },
  websocket: {
    open(ws) {
      const { roomId } = ws.data;
      if (!sockets.has(roomId)) {
        sockets.set(roomId, new Set());
        gameStore.createRoom(roomId);
      }
      sockets.get(roomId)!.add(ws);
      gameStore.joinRoom(roomId, crypto.randomUUID());
    },
    close(ws) {
      const { roomId } = ws.data;
      sockets.get(roomId)?.delete(ws);
    },
    message(ws, message) {
      // Handle incoming messages
    },
  },
});

Pattern 3: Persistent State with SQLite

Bun ships with a built-in SQLite driver (bun:sqlite), making it trivial to persist state without external dependencies. This is one of Bun's standout features for state management, as SQLite is embedded directly in the process with near-zero configuration.

// database.ts
import { Database } from "bun:sqlite";

const db = new Database("app.db", { create: true });

// Enable WAL mode for better concurrent read performance
db.exec("PRAGMA journal_mode = WAL;");

db.exec(`
  CREATE TABLE IF NOT EXISTS sessions (
    id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL,
    data TEXT,
    created_at INTEGER NOT NULL,
    expires_at INTEGER NOT NULL
  );

  CREATE TABLE IF NOT EXISTS kv_store (
    key TEXT PRIMARY KEY,
    value TEXT NOT NULL,
    updated_at INTEGER NOT NULL
  );

  CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
`);

// Prepared statements for performance
const insertSession = db.prepare(
  "INSERT INTO sessions (id, user_id, data, created_at, expires_at) VALUES (?, ?, ?, ?, ?)"
);

const getSession = db.prepare("SELECT * FROM sessions WHERE id = ?");

const deleteExpired = db.prepare("DELETE FROM sessions WHERE expires_at < ?");

const upsertKV = db.prepare(`
  INSERT INTO kv_store (key, value, updated_at) VALUES (?, ?, ?)
  ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`);

const getKV = db.prepare("SELECT value FROM kv_store WHERE key = ?");

export const sessionDb = {
  create(id: string, userId: string, data: object, ttlMs: number) {
    const now = Date.now();
    insertSession.run(id, userId, JSON.stringify(data), now, now + ttlMs);
  },

  get(id: string) {
    const row = getSession.get(id) as any;
    if (!row) return null;
    if (row.expires_at < Date.now()) {
      return null;
    }
    return { ...row, data: JSON.parse(row.data) };
  },

  cleanup() {
    return deleteExpired.run(Date.now());
  },

  set(key: string, value: any) {
    upsertKV.run(key, JSON.stringify(value), Date.now());
  },

  getKV(key: string) {
    const row = getKV.get(key) as any;
    return row ? JSON.parse(row.value) : null;
  },
};

Using this in your application:

import { sessionDb } from "./database";

// Create a session
const sessionId = crypto.randomUUID();
sessionDb.create(sessionId, "user-123", { role: "admin", preferences: { theme: "dark" } }, 3600_000);

// Retrieve session data
const session = sessionDb.get(sessionId);
console.log(session?.data.preferences.theme); // "dark"

// Use key-value store for app config
sessionDb.set("feature_flags", { newUI: true, betaAccess: false });
const flags = sessionDb.getKV("feature_flags");
console.log(flags.newUI); // true

Pattern 4: Using External Libraries

While Bun's built-in capabilities are powerful, you may want to use established libraries for more complex state management needs. Bun's npm compatibility means you can install and use most Node.js packages seamlessly.

Keyv for Key-Value Storage

Keyv is a popular key-value store with support for multiple storage backends:

// Install: bun add keyv @keyv/sqlite

import Keyv from "keyv";

const cache = new Keyv("sqlite://cache.db", { namespace: "api-cache", ttl: 60000 });

// Set a value with automatic expiration
await cache.set("user:123:profile", { name: "Alice", email: "alice@example.com" });

// Get a value
const profile = await cache.get("user:123:profile");
console.log(profile); // { name: "Alice", email: "alice@example.com" }

// Delete a value
await cache.delete("user:123:profile");

// Clear entire namespace
await cache.clear();

Redis for Distributed State

When your application needs to scale horizontally, Redis provides a fast, shared state layer accessible across multiple Bun processes:

// Install: bun add ioredis

import Redis from "ioredis";

const redis = new Redis("redis://localhost:6379");

class DistributedRateLimiter {
  constructor(private redis: Redis, private maxRequests: number, private windowMs: number) {}

  async check(identifier: string): Promise<{ allowed: boolean; remaining: number }> {
    const key = `rate:${identifier}`;
    const now = Date.now();
    const windowStart = now - this.windowMs;

    const pipeline = this.redis.pipeline();
    pipeline.zremrangebyscore(key, 0, windowStart);
    pipeline.zadd(key, now, `${now}`);
    pipeline.zcard(key);
    pipeline.pexpire(key, this.windowMs);

    const results = await pipeline.exec();
    const count = results![2][1] as number;

    return {
      allowed: count <= this.maxRequests,
      remaining: Math.max(0, this.maxRequests - count),
    };
  }
}

const limiter = new DistributedRateLimiter(redis, 100, 60_000);

// Usage in a request handler
async function handleRequest(req: Request): Promise<Response> {
  const ip = req.headers.get("x-forwarded-for") || "unknown";
  const { allowed, remaining } = await limiter.check(ip);

  if (!allowed) {
    return new Response("Rate limit exceeded", {
      status: 429,
      headers: { "X-RateLimit-Remaining": "0" },
    });
  }

  return new Response("OK", {
    headers: { "X-RateLimit-Remaining": String(remaining) },
  });
}

Pattern 5: Reactive State with Proxies

For a more modern, reactive approach, you can use JavaScript Proxies to create observable state that automatically notifies subscribers when changes occur. This pattern brings frontend-style reactivity to the server.

// reactive.ts
type Listener<T> = (state: T, path: string) => void;

export function createReactiveState<T extends object>(initial: T): {
  state: T;
  subscribe: (listener: Listener<T>) => () => void;
} {
  const listeners = new Set<Listener<T>>();

  function createProxy(obj: any, path: string): any {
    return new Proxy(obj, {
      get(target, key, receiver) {
        const value = Reflect.get(target, key, receiver);
        if (value && typeof value === "object" && !Array.isArray(value)) {
          return createProxy(value, `${path}.${String(key)}`);
        }
        return value;
      },
      set(target, key, value, receiver) {
        const result = Reflect.set(target, key, value, receiver);
        const fullPath = `${path}.${String(key)}`;
        for (const listener of listeners) {
          listener(proxy, fullPath);
        }
        return result;
      },
    });
  }

  const proxy = createProxy(initial, "root");

  return {
    state: proxy,
    subscribe(listener: Listener<T>) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

Example usage in a real-time dashboard:

import { createReactiveState } from "./reactive";

const { state, subscribe } = createReactiveState({
  connectedClients: 0,
  messagesSent: 0,
  errors: [] as string[],
});

// Log every state change
subscribe((newState, path) => {
  console.log(`[State Change] ${path} =>`, JSON.stringify(newState));
});

// Broadcast changes to connected WebSocket clients
subscribe((newState) => {
  broadcastToClients({
    type: "metrics_update",
    data: {
      connectedClients: newState.connectedClients,
      messagesSent: newState.messagesSent,
    },
  });
});

// Mutations automatically trigger listeners
state.connectedClients++;        // triggers subscribers
state.messagesSent += 5;         // triggers subscribers
state.errors.push("Timeout");    // triggers subscribers

Best Practices for State Management in Bun

Choose the Right Storage Layer

Not all state belongs in the same place. A common mistake is over-relying on a single storage mechanism. Consider these guidelines:

Implement Proper Cleanup

Memory leaks are the most common state management pitfall. Always implement cleanup mechanisms:

// Graceful shutdown handler
process.on("SIGTERM", async () => {
  console.log("Shutting down gracefully...");

  // Close WebSocket connections
  for (const [, socketSet] of sockets) {
    for (const ws of socketSet) {
      ws.close(1001, "Server shutting down");
    }
  }

  // Flush any pending state to persistent storage
  await flushPendingWrites();

  // Close database connections
  db.close();

  process.exit(0);
});

// Periodic cleanup intervals
setInterval(() => {
  sessionDb.cleanup();
  userStore.removeStaleUsers(30 * 60 * 1000);
}, 5 * 60 * 1000);

Use Type Safety

Bun supports TypeScript natively. Leverage types to prevent state-related bugs:

// Define clear interfaces for your state
interface AppState {
  config: AppConfig;
  connections: Map<string, Connection>;
  metrics: Metrics;
}

interface AppConfig {
  port: number;
  maxConnections: number;
  environment: "development" | "production";
}

// Use a typed singleton pattern
let appState: AppState | null = null;

export function getAppState(): AppState {
  if (!appState) {
    appState = {
      config: loadConfig(),
      connections: new Map(),
      metrics: { requests: 0, errors: 0, avgResponseTime: 0 },
    };
  }
  return appState;
}

Avoid Global Mutable State When Possible

While module-level singletons are convenient, they make testing harder and create hidden dependencies. Prefer dependency injection for complex applications:

// Instead of importing a global store directly:
import { userStore } from "./store"; // Hard to mock in tests

// Use dependency injection:
interface IUserStore {
  getUser(id: string): User | undefined;
  addUser(user: User): void;
}

class UserService {
  constructor(private store: IUserStore) {}

  authenticate(token: string): User | null {
    return this.store.getUser(token) ?? null;
  }
}

// In production:
const userService = new UserService(userStore);

// In tests:
const mockStore: IUserStore = {
  getUser: (id) => id === "test" ? { id: "test", name: "Test", lastSeen: 0 } : undefined,
  addUser: () => {},
};
const testService = new UserService(mockStore);

Handle Concurrency Carefully

Although Bun runs JavaScript in a single thread, async operations can interleave in surprising ways. Use atomic operations or locks when dealing with critical state transitions:

// Simple async mutex for critical sections
class Mutex {
  private queue: (() => void)[] = [];
  private locked = false;

  async acquire(): Promise<void> {
    if (!this.locked) {
      this.locked = true;
      return;
    }
    return new Promise((resolve) => {
      this.queue.push(resolve);
    });
  }

  release(): void {
    const next = this.queue.shift();
    if (next) {
      next();
    } else {
      this.locked = false;
    }
  }
}

const inventoryMutex = new Mutex();

async function purchaseItem(itemId: string, quantity: number): Promise<boolean> {
  await inventoryMutex.acquire();
  try {
    const available = await getInventory(itemId);
    if (available < quantity) {
      return false;
    }
    await updateInventory(itemId, available - quantity);
    return true;
  } finally {
    inventoryMutex.release();
  }
}

Conclusion

State management in Bun spans a spectrum from simple in-memory module singletons to distributed systems backed by Redis and databases. The key is matching your state management approach to your application's needs: use in-memory stores for speed-critical ephemeral data, leverage Bun's built-in SQLite for persistent local state, and adopt Redis or external databases when you need to scale across processes. By combining the patterns covered in this tutorial โ€” module-based stores, event-driven architectures, persistent storage with SQLite, reactive proxies, and established libraries like Keyv and ioredis โ€” you can build Bun applications that are both fast and maintainable. Remember to prioritize proper cleanup, type safety, and thoughtful concurrency handling to avoid the common pitfalls that plague server-side state management. Bun's performance and modern tooling give you an excellent foundation; the patterns you choose to build on top of it determine the long-term health of your application.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles