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:
- Request-scoped state โ data that lives only for the duration of a single request, such as the authenticated user, request ID, or trace context.
- Application-scoped state โ configuration and shared resources like database connections, API keys, or feature flags that persist across requests.
- Persistent state โ data stored externally in databases, key-value stores, or session backends that survives process restarts.
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:
- Predictability โ handlers always know where to find the data they need.
- Testability โ dependencies can be injected and mocked cleanly.
- Performance โ avoiding redundant lookups and unnecessary serialization.
- Security โ preventing cross-request data leakage in shared runtimes.
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
- Always type your context variables. Use the
Variablesgeneric onHonoto get compile-time safety and autocomplete. - Keep handlers thin. Move state initialization into middleware so handlers focus on business logic.
- Avoid global mutable state on serverless. Prefer environment bindings and external stores over module-level variables that may not persist across invocations.
- Use httpOnly, secure cookies for sessions. Never store sensitive data in client-readable cookies without encryption.
- Invalidate caches explicitly. When underlying data changes, delete or overwrite cache entries to avoid stale responses.
- Separate read and write state paths. Use middleware to load state for reads, and dedicated handlers for mutations, to keep your code predictable.
- Test with injected dependencies. The DI pattern lets you swap real services for mocks without touching handler code.
- Be mindful of serialization costs. On edge runtimes, avoid storing large objects in context variables that get cloned across boundaries.
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.