โ† Back to DevBytes

State Management in Hono: Patterns and Libraries

Introduction to State Management in Hono

Hono is a fast, lightweight web framework that runs on multiple JavaScript runtimes โ€” Cloudflare Workers, Deno, Bun, and Node.js. While Hono is often praised for its minimal footprint and edge-first design, building real-world applications requires thoughtful handling of state: request-scoped data, shared configuration, user sessions, and cached values. This tutorial explores the patterns and libraries you can use to manage state effectively in Hono applications.

What Is State Management in Hono?

State management in Hono refers to how you store, access, and propagate data across the request lifecycle and beyond. There are three primary categories of state you will encounter:

Hono provides built-in primitives like c.set() and c.get() for request-scoped state, middleware for cross-cutting concerns, and a flexible context system. For more advanced needs, you can integrate external libraries and storage backends.

Why State Management Matters

Poor state management leads to subtle bugs: leaked user data between requests, race conditions in shared resources, and bloated handlers that are hard to test. On edge runtimes, the stakes are higher because instances are ephemeral and may be created or destroyed at any time. A clear state management strategy gives you:

Request-Scoped State with Context Variables

The simplest and most common form of state in Hono is request-scoped context variables. Hono's Context object provides set, get, and var for storing typed data per request.

Basic Usage

import { Hono } from 'hono'

const app = new Hono()

app.use('*', async (c, next) => {
  c.set('requestId', crypto.randomUUID())
  c.set('startTime', Date.now())
  await next()
})

app.get('/', (c) => {
  const requestId = c.get('requestId')
  const startTime = c.get('startTime')
  const duration = Date.now() - startTime
  return c.json({ requestId, durationMs: duration })
})

export default app

Type-Safe Context Variables

By default, c.get() returns any. You can make your context variables type-safe by declaring a Variables type when constructing your Hono instance.

import { Hono } from 'hono'

type AppVariables = {
  requestId: string
  startTime: number
  user: {
    id: string
    email: string
    roles: string[]
  } | null
}

const app = new Hono<{ Variables: AppVariables }>()

app.use('/api/*', async (c, next) => {
  c.set('requestId', crypto.randomUUID())
  c.set('startTime', Date.now())
  c.set('user', null)
  await next()
})

app.get('/api/me', (c) => {
  const user = c.get('user')
  if (!user) {
    return c.json({ error: 'Unauthorized' }, 401)
  }
  return c.json({ user })
})

With this typing, TypeScript will enforce the correct types when you call c.get('user'), eliminating an entire class of runtime errors.

Middleware Patterns for State Initialization

Middleware is the natural place to populate request-scoped state. A common pattern is to create reusable middleware factories that load data into the context before handlers run.

Authentication Middleware

import { Hono } from 'hono'
import { verify } from 'hono/jwt'

type AuthVariables = {
  user: { id: string; email: string; roles: string[] }
}

const app = new Hono<{ Variables: AuthVariables }>()

const authMiddleware = (requiredRole?: string) => {
  return async (c, next) => {
    const authHeader = c.req.header('Authorization')
    if (!authHeader?.startsWith('Bearer ')) {
      return c.json({ error: 'Missing token' }, 401)
    }

    const token = authHeader.slice(7)
    try {
      const payload = await verify(token, c.env.JWT_SECRET)
      const user = {
        id: payload.sub as string,
        email: payload.email as string,
        roles: (payload.roles as string[]) ?? [],
      }
      c.set('user', user)

      if (requiredRole && !user.roles.includes(requiredRole)) {
        return c.json({ error: 'Insufficient permissions' }, 403)
      }
      await next()
    } catch {
      return c.json({ error: 'Invalid token' }, 401)
    }
  }
}

app.get('/profile', authMiddleware(), (c) => {
  const user = c.get('user')
  return c.json({ profile: user })
})

app.delete('/admin/users/:id', authMiddleware('admin'), (c) => {
  const targetId = c.req.param('id')
  return c.json({ deleted: targetId })
})

Request Tracing Middleware

const tracingMiddleware = async (c, next) => {
  const traceId = c.req.header('X-Trace-Id') ?? crypto.randomUUID()
  c.set('traceId', traceId)
  c.header('X-Trace-Id', traceId)
  await next()
}

Application-Scoped State

Application-scoped state includes resources that should be initialized once and shared across all requests. On traditional servers, you might use global variables. On edge runtimes, the recommended approach is to use Hono's env binding or a module-level singleton guarded by initialization logic.

Using Environment Bindings

import { Hono } from 'hono'
import { drizzle } from 'drizzle-orm/d1'

type AppEnv = {
  Bindings: {
    DB: D1Database
    API_KEY: string
    CACHE: KVNamespace
  }
}

const app = new Hono<AppEnv>()

app.get('/users', async (c) => {
  const db = drizzle(c.env.DB)
  const users = await db.query.users.findMany()
  return c.json({ users })
})

app.get('/config', (c) => {
  return c.json({ hasApiKey: !!c.env.API_KEY })
})

Lazy Initialization Pattern

For resources that are expensive to create, use a lazy initialization pattern that creates the resource on first access and caches it.

import { Hono } from 'hono'

let dbConnection: Database | null = null

async function getDatabase(env: Bindings): Promise<Database> {
  if (!dbConnection) {
    dbConnection = await createDatabaseConnection(env.DATABASE_URL)
  }
  return dbConnection
}

const app = new Hono()

app.get('/items', async (c) => {
  const db = await getDatabase(c.env)
  const items = await db.query('SELECT * FROM items')
  return c.json({ items })
})

Be cautious with module-level singletons on serverless platforms. If the runtime isolates requests across multiple instances, a singleton in one instance will not be visible to another. Always prefer environment bindings when the platform provides them.

Session Management

Sessions are a specialized form of persistent state. Hono does not ship a built-in session library, but it integrates cleanly with cookie-based and store-backed session solutions.

Cookie-Based Sessions with Signed Cookies

import { Hono } from 'hono'
import { getCookie, setCookie } from 'hono/cookie'

const app = new Hono()

const SESSION_SECRET = process.env.SESSION_SECRET!

function encodeSession(data: object): string {
  const json = JSON.stringify(data)
  return btoa(json)
}

function decodeSession(encoded: string): object | null {
  try {
    return JSON.parse(atob(encoded))
  } catch {
    return null
  }
}

app.post('/login', async (c) => {
  const body = await c.req.json<{ email: string; password: string }>()
  const user = await authenticate(body.email, body.password)
  if (!user) {
    return c.json({ error: 'Invalid credentials' }, 401)
  }

  const session = encodeSession({ userId: user.id, email: user.email })
  setCookie(c, 'session', session, {
    httpOnly: true,
    secure: true,
    sameSite: 'Strict',
    maxAge: 60 * 60 * 24 * 7,
    path: '/',
  })
  return c.json({ success: true })
})

app.get('/dashboard', (c) => {
  const raw = getCookie(c, 'session')
  if (!raw) return c.json({ error: 'Not authenticated' }, 401)
  const session = decodeSession(raw)
  if (!session) return c.json({ error: 'Invalid session' }, 401)
  return c.json({ session })
})

Store-Backed Sessions with KV

For production applications, store session data in a key-value store and keep only a session ID in the cookie. This approach supports larger session payloads and easier revocation.

import { Hono } from 'hono'
import { getCookie, setCookie, deleteCookie } from 'hono/cookie'

type AppEnv = {
  Bindings: {
    SESSIONS: KVNamespace
    SESSION_SECRET: string
  }
  Variables: {
    session: { userId: string; email: string } | null
  }
}

const app = new Hono<AppEnv>()

const sessionMiddleware = async (c, next) => {
  const sessionId = getCookie(c, 'sid')
  if (sessionId) {
    const raw = await c.env.SESSIONS.get(`session:${sessionId}`)
    if (raw) {
      c.set('session', JSON.parse(raw))
    } else {
      c.set('session', null)
    }
  } else {
    c.set('session', null)
  }
  await next()
}

app.use('/api/*', sessionMiddleware)

app.post('/api/login', async (c) => {
  const { email, password } = await c.req.json()
  const user = await authenticate(email, password)
  if (!user) return c.json({ error: 'Invalid credentials' }, 401)

  const sessionId = crypto.randomUUID()
  const sessionData = { userId: user.id, email: user.email }
  await c.env.SESSIONS.put(
    `session:${sessionId}`,
    JSON.stringify(sessionData),
    { expirationTtl: 60 * 60 * 24 * 7 }
  )

  setCookie(c, 'sid', sessionId, {
    httpOnly: true,
    secure: true,
    sameSite: 'Strict',
    maxAge: 60 * 60 * 24 * 7,
    path: '/',
  })
  return c.json({ success: true })
})

app.post('/api/logout', async (c) => {
  const sessionId = getCookie(c, 'sid')
  if (sessionId) {
    await c.env.SESSIONS.delete(`session:${sessionId}`)
    deleteCookie(c, 'sid')
  }
  return c.json({ success: true })
})

app.get('/api/me', (c) => {
  const session = c.get('session')
  if (!session) return c.json({ error: 'Not authenticated' }, 401)
  return c.json({ user: session })
})

Caching as State

Caching is a form of transient state that can dramatically improve performance. On edge platforms, KV stores and the Cache API are natural choices.

In-Memory Cache with TTL

type CacheEntry<T> = {
  value: T
  expiresAt: number
}

class TTLCache {
  private store = new Map<string, CacheEntry<unknown>>()

  set<T>(key: string, value: T, ttlMs: number): void {
    this.store.set(key, { value, expiresAt: Date.now() + ttlMs })
  }

  get<T>(key: string): T | null {
    const entry = this.store.get(key)
    if (!entry) return null
    if (Date.now() > entry.expiresAt) {
      this.store.delete(key)
      return null
    }
    return entry.value as T
  }

  delete(key: string): void {
    this.store.delete(key)
  }
}

const cache = new TTLCache()

app.get('/api/products/:id', async (c) => {
  const id = c.req.param('id')
  const cached = cache.get(`product:${id}`)
  if (cached) {
    return c.json(cached)
  }
  const product = await fetchProduct(id)
  cache.set(`product:${id}`, product, 60_000)
  return c.json(product)
})

Using the Cache API on Edge Runtimes

app.get('/api/expensive/:id', async (c) => {
  const id = c.req.param('id')
  const cacheKey = new Request(`https://cache.local/api/expensive/${id}`)

  const cached = await caches.default.match(cacheKey)
  if (cached) {
    return new Response(cached.body, cached)
  }

  const data = await computeExpensiveOperation(id)
  const response = new Response(JSON.stringify(data), {
    headers: {
      'Content-Type': 'application/json',
      'Cache-Control': 'max-age=300',
    },
  })
  c.executionCtx.waitUntil(caches.default.put(cacheKey, response.clone()))
  return response
})

Using Hono Middleware Libraries for State

Hono's ecosystem includes middleware that helps manage specific kinds of state. Here are some commonly used packages.

hono/logger for Request State Visibility

import { Hono } from 'hono'
import { logger } from 'hono/logger'

const app = new Hono()
app.use(logger())

app.get('/', (c) => c.text('Hello'))

hono/secure-session for Encrypted Sessions

The hono/secure-session middleware provides encrypted, tamper-proof session cookies without requiring an external store.

import { Hono } from 'hono'
import { secureSession } from 'hono/secure-session'

const app = new Hono()

app.use('*', secureSession({
  keys: [process.env.SESSION_KEY!],
  cookie: {
    name: 'session',
    httpOnly: true,
    secure: true,
    sameSite: 'Strict',
  },
}))

app.post('/login', async (c) => {
  const { email, password } = await c.req.json()
  const user = await authenticate(email, password)
  if (!user) return c.json({ error: 'Invalid' }, 401)

  c.session.set('userId', user.id)
  c.session.set('email', user.email)
  return c.json({ success: true })
})

app.get('/me', (c) => {
  const userId = c.session.get('userId')
  if (!userId) return c.json({ error: 'Unauthorized' }, 401)
  return c.json({ userId })
})

app.post('/logout', (c) => {
  c.session.delete()
  return c.json({ success: true })
})

Dependency Injection as State Strategy

For larger applications, consider a dependency injection pattern where services are passed through the context. This makes handlers easy to test and keeps business logic decoupled from infrastructure.

import { Hono } from 'hono'

type Services = {
  userService: UserService
  emailService: EmailService
  cache: TTLCache
}

type AppBindings = {
  Variables: {
    services: Services
  }
}

const app = new Hono<AppBindings>()

function createApp(services: Services) {
  const app = new Hono<AppBindings>()

  app.use('*', async (c, next) => {
    c.set('services', services)
    await next()
  })

  app.post('/users', async (c) => {
    const { services } = c.var
    const body = await c.req.json()
    const user = await services.userService.create(body)
    await services.emailService.sendWelcome(user.email)
    return c.json({ user }, 201)
  })

  return app
}

// Production wiring
const prodApp = createApp({
  userService: new DbUserService(database),
  emailService: new SmtpEmailService(config),
  cache: new TTLCache(),
})

// Test wiring
const testApp = createApp({
  userService: new MockUserService(),
  emailService: new MockEmailService(),
  cache: new TTLCache(),
})

Best Practices

Conclusion

State management in Hono is flexible by design. The framework gives you lightweight primitives โ€” context variables, middleware, and environment bindings โ€” that scale from simple request-scoped data to complex session and caching strategies. By typing your context variables, centralizing initialization in middleware, choosing the right storage backend for each kind of state, and applying dependency injection for testability, you can build Hono applications that remain fast, secure, and maintainable as they grow. Whether you are deploying to Cloudflare Workers, Deno Deploy, or a traditional Node.js server, these patterns will keep your state handling predictable and your handlers clean.

๐Ÿ›  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