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:
- User sessions โ tracking authenticated users across requests
- Cached data โ storing frequently accessed results to reduce database load
- Application configuration โ runtime settings loaded from environment variables or files
- Real-time connections โ managing WebSocket clients and their associated data
- Background job queues โ tracking in-flight tasks and their statuses
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:
- Use in-memory stores for ephemeral, high-frequency data like request-scoped caches or active WebSocket connections.
- Use SQLite for persistent, local state that survives restarts but does not need to be shared across processes.
- Use Redis for shared state across multiple instances, distributed locks, and pub/sub messaging.
- Use a proper database (PostgreSQL, MySQL) for transactional, relational data.
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.