State Management in Fastify: Patterns and Libraries
State management is one of the most important architectural decisions when building a Fastify application. Whether you need to share configuration values across routes, maintain per-request context, or cache data between requests, understanding how to manage state effectively will make your applications more maintainable, testable, and performant. In this tutorial, we will explore the built-in mechanisms Fastify provides for state management, popular patterns used by the community, and third-party libraries that integrate well with the framework.
What Is State Management in Fastify?
In the context of a Fastify application, state refers to any data that needs to be stored and accessed during the lifecycle of the server or during the processing of a single request. State can be broadly categorized into three scopes:
- Application state โ data shared across the entire server instance, such as configuration values, database connection pools, or cached responses.
- Request state โ data scoped to a single HTTP request, such as the authenticated user, request-scoped loggers, or transaction objects.
- Plugin state โ data encapsulated within a Fastify plugin, isolated from other parts of the application unless explicitly shared.
Fastify provides several mechanisms to handle each of these scopes, and choosing the right one for each situation is critical for building robust applications.
Why State Management Matters
Poor state management leads to several common problems: memory leaks from global variables, race conditions in concurrent requests, tightly coupled code that is difficult to test, and security vulnerabilities such as data leaking between requests. Fastify was designed with these concerns in mind, offering an encapsulation model that encourages clean separation of concerns. By leveraging Fastify's built-in state management features, you can avoid the pitfalls of ad-hoc global variables while still keeping your code simple and readable.
Application State with decorate
The primary mechanism for managing application-level state in Fastify is the decorate API. Decoration allows you to attach custom properties to the Fastify instance, the request object, or the reply object. These decorated properties are then available throughout your application.
const fastify = require('fastify')({ logger: true })
// Decorate the Fastify instance with application-level state
fastify.decorate('config', {
apiVersion: 'v1',
maxRetries: 3,
featureFlags: {
newDashboard: true,
betaFeatures: false
}
})
// Decorate with a database connection pool
fastify.decorate('db', {
pool: null,
async query(sql, params) {
// Use the connection pool to execute queries
return this.pool.query(sql, params)
}
})
// Initialize the database pool on startup
fastify.addHook('onReady', async () => {
const { Pool } = require('pg')
fastify.db.pool = new Pool({
connectionString: process.env.DATABASE_URL
})
fastify.log.info('Database pool initialized')
})
fastify.get('/health', async (request, reply) => {
// Access application state via the fastify instance
return {
status: 'ok',
version: fastify.config.apiVersion,
retries: fastify.config.maxRetries
}
})
fastify.listen({ port: 3000 })
In this example, the config and db objects are decorated onto the Fastify instance. They are available in every route handler, hook, and plugin that has access to the instance. This is the recommended way to share singleton resources across your application.
Request-Level State with decorateRequest
For data that should be scoped to a single request, Fastify provides decorateRequest. This is commonly used to store authentication information, request-scoped tracing IDs, or any data that should not leak between concurrent requests.
const fastify = require('fastify')({ logger: true })
// Decorate the request object with default values
fastify.decorateRequest('user', null)
fastify.decorateRequest('requestId', null)
// Add a hook to populate request-scoped state
fastify.addHook('onRequest', async (request, reply) => {
const authHeader = request.headers.authorization
if (authHeader) {
try {
const token = authHeader.replace('Bearer ', '')
const decoded = await verifyToken(token)
request.user = decoded
} catch (err) {
request.user = null
}
}
request.requestId = request.id
})
// Use request-scoped state in route handlers
fastify.get('/profile', async (request, reply) => {
if (!request.user) {
return reply.code(401).send({ error: 'Unauthorized' })
}
return {
requestId: request.requestId,
user: request.user
}
})
async function verifyToken(token) {
// Simulated token verification
return { id: 123, email: 'user@example.com', role: 'admin' }
}
fastify.listen({ port: 3000 })
It is important to note that when decorating the request object, you should always provide a default value. Fastify reuses request objects for performance, so failing to reset decorated properties between requests can lead to data leakage. Always set default values in the decorateRequest call and reset them in hooks if necessary.
Plugin Encapsulation and State Sharing
Fastify plugins create isolated contexts by default. This means that decorations made inside a plugin are not visible to the parent or sibling plugins unless the plugin is wrapped with fastify-plugin. This encapsulation model is powerful for organizing state, but you need to understand how to share state when necessary.
const fastify = require('fastify')()
const fp = require('fastify-plugin')
// A plugin that encapsulates its own state
async function privatePlugin(fastify, opts) {
// This decoration is only visible within this plugin
fastify.decorate('privateData', 'secret')
fastify.get('/private', async (request, reply) => {
return { data: fastify.privateData }
})
}
// A plugin that shares its state with the parent
async function sharedPlugin(fastify, opts) {
fastify.decorate('sharedData', 'available everywhere')
fastify.decorate('getShared', function () {
return this.sharedData
})
}
// Wrap with fastify-plugin to break encapsulation
const sharedPluginWrapped = fp(sharedPlugin, {
name: 'shared-plugin'
})
fastify.register(privatePlugin)
fastify.register(sharedPluginWrapped)
fastify.get('/check', async (request, reply) => {
// fastify.privateData would be undefined here
// fastify.sharedData is available because of fastify-plugin
return {
shared: fastify.sharedData,
viaGetter: fastify.getShared()
}
})
fastify.listen({ port: 3000 })
Using fastify-plugin is the standard way to share state from a plugin with the rest of the application. However, you should use it judiciously. Overusing it defeats the purpose of encapsulation and can lead to naming collisions and tight coupling.
Using TypeScript for Type-Safe State
When using TypeScript, you can extend Fastify's type definitions to get type safety for your decorated state. This prevents runtime errors caused by accessing undefined properties or passing the wrong types.
import { FastifyInstance, FastifyRequest } from 'fastify'
// Define your application state types
declare module 'fastify' {
interface FastifyInstance {
config: AppConfig
db: DatabaseService
}
interface FastifyRequest {
user: AuthUser | null
requestId: string
}
}
interface AppConfig {
apiVersion: string
maxRetries: number
featureFlags: Record<string, boolean>
}
interface DatabaseService {
query: (sql: string, params?: unknown[]) => Promise<unknown[]>
}
interface AuthUser {
id: number
email: string
role: string
}
const fastify = require('fastify')()
// TypeScript now knows the shape of fastify.config
fastify.decorate('config', {
apiVersion: 'v1',
maxRetries: 3,
featureFlags: { newDashboard: true }
})
fastify.decorateRequest('user', null)
fastify.decorateRequest('requestId', null)
fastify.get('/profile', async (request: FastifyRequest, reply) => {
// TypeScript enforces null checking
if (!request.user) {
return reply.code(401).send({ error: 'Unauthorized' })
}
// Type-safe access to user properties
return { email: request.user.email, role: request.user.role }
})
fastify.listen({ port: 3000 })
This approach gives you compile-time guarantees that your state is being used correctly, which is especially valuable in large codebases with many developers.
Using AsyncLocalStorage for Request Context
Sometimes you need to access request-scoped state deep in your call stack without passing the request object through every function. Node.js provides AsyncLocalStorage for this purpose, and it integrates cleanly with Fastify.
const fastify = require('fastify')({ logger: true })
const { AsyncLocalStorage } = require('async_hooks')
// Create an async local storage instance for request context
const requestContext = new AsyncLocalStorage()
// Decorate so it is accessible
fastify.decorate('requestContext', requestContext)
// Populate the context on every request
fastify.addHook('onRequest', async (request, reply) => {
const context = {
requestId: request.id,
user: null,
startTime: Date.now()
}
requestContext.enterWith(context)
})
// A deeply nested function can access context without parameters
async function processOrder(orderId) {
const ctx = requestContext.getStore()
// Access request-scoped data anywhere in the call chain
console.log(`Processing order ${orderId} for request ${ctx.requestId}`)
if (ctx.user) {
console.log(`User: ${ctx.user.email}`)
}
return { orderId, processedAt: new Date().toISOString() }
}
fastify.post('/orders', async (request, reply) => {
const result = await processOrder(request.body.orderId)
return result
})
fastify.listen({ port: 3000 })
This pattern is particularly useful in large applications where passing the request object through many layers of service code would create unnecessary coupling. However, be aware that AsyncLocalStorage has a small performance overhead, so measure its impact in high-throughput scenarios.
Caching State with Fastify Plugins
For caching application state such as frequently accessed data or computed results, several Fastify plugins are available. The @fastify/caching plugin provides a simple abstraction for in-memory caching.
const fastify = require('fastify')({ logger: true })
// Register the caching plugin
fastify.register(require('@fastify/caching'), {
privacy: 'private',
expiresIn: 300 // 5 minutes
})
// Or use a custom cache for application-level state
fastify.register(async (fastify, opts) => {
const cache = new Map()
fastify.decorate('cache', {
get(key) {
const entry = cache.get(key)
if (!entry) return null
if (Date.now() > entry.expiresAt) {
cache.delete(key)
return null
}
return entry.value
},
set(key, value, ttlSeconds = 300) {
cache.set(key, {
value,
expiresAt: Date.now() + (ttlSeconds * 1000)
})
},
invalidate(key) {
cache.delete(key)
},
clear() {
cache.clear()
}
})
})
fastify.get('/users/:id', async (request, reply) => {
const { id } = request.params
const cacheKey = `user:${id}`
// Check cache first
const cached = fastify.cache.get(cacheKey)
if (cached) {
reply.header('X-Cache', 'HIT')
return cached
}
// Fetch from database
const user = await fetchUserFromDatabase(id)
// Store in cache for 5 minutes
fastify.cache.set(cacheKey, user, 300)
reply.header('X-Cache', 'MISS')
return user
})
async function fetchUserFromDatabase(id) {
// Simulated database fetch
return { id: parseInt(id), name: 'John Doe', email: 'john@example.com' }
}
fastify.listen({ port: 3000 })
Session State with @fastify/session
For managing user session state across multiple requests, the @fastify/session plugin (used together with @fastify/cookie) provides a familiar session-based approach.
const fastify = require('fastify')({ logger: true })
// Register cookie plugin first
fastify.register(require('@fastify/cookie'), {
secret: 'my-secret-key-change-in-production'
})
// Register session plugin
fastify.register(require('@fastify/session'), {
secret: 'another-secret-key-change-in-production',
cookie: {
secure: false, // Set to true in production with HTTPS
httpOnly: true,
maxAge: 3600000 // 1 hour
}
})
fastify.post('/login', async (request, reply) => {
const { email, password } = request.body
const user = await authenticateUser(email, password)
if (!user) {
return reply.code(401).send({ error: 'Invalid credentials' })
}
// Store user data in session state
request.session.user = {
id: user.id,
email: user.email,
role: user.role
}
return { message: 'Logged in successfully' }
})
fastify.get('/dashboard', async (request, reply) => {
// Access session state
if (!request.session.user) {
return reply.code(401).send({ error: 'Please log in' })
}
return {
message: `Welcome ${request.session.user.email}`,
role: request.session.user.role
}
})
fastify.post('/logout', async (request, reply) => {
// Destroy session state
request.session.destroy((err) => {
if (err) {
reply.code(500).send({ error: 'Logout failed' })
} else {
reply.send({ message: 'Logged out successfully' })
}
})
})
async function authenticateUser(email, password) {
// Simulated authentication
if (email === 'admin@example.com' && password === 'password') {
return { id: 1, email, role: 'admin' }
}
return null
}
fastify.listen({ port: 3000 })
State Management with Redis
For distributed applications running multiple instances, in-memory state is not sufficient. Redis is a popular choice for shared state management, and it integrates well with Fastify through the fastify-redis plugin or direct client usage.
const fastify = require('fastify')({ logger: true })
const redis = require('redis')
// Create a Redis client and decorate the instance
fastify.register(async (fastify) => {
const client = redis.createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379'
})
client.on('error', (err) => fastify.log.error(err, 'Redis error'))
await client.connect()
fastify.decorate('redis', client)
// Clean up on close
fastify.addHook('onClose', async () => {
await client.quit()
})
})
// Use Redis for rate limiting state
fastify.register(async (fastify) => {
fastify.decorate('rateLimiter', {
async check(key, limit, windowSeconds) {
const redisKey = `ratelimit:${key}`
const current = await fastify.redis.incr(redisKey)
if (current === 1) {
await fastify.redis.expire(redisKey, windowSeconds)
}
return {
allowed: current <= limit,
remaining: Math.max(0, limit - current),
limit
}
}
})
})
fastify.addHook('onRequest', async (request, reply) => {
const ip = request.ip
const result = await fastify.rateLimiter.check(ip, 100, 60)
reply.header('X-RateLimit-Limit', result.limit)
reply.header('X-RateLimit-Remaining', result.remaining)
if (!result.allowed) {
return reply.code(429).send({ error: 'Rate limit exceeded' })
}
})
fastify.get('/data', async (request, reply) => {
// Use Redis as a shared cache
const cacheKey = 'data:all'
const cached = await fastify.redis.get(cacheKey)
if (cached) {
return JSON.parse(cached)
}
const data = await fetchExpensiveData()
await fastify.redis.setEx(cacheKey, 300, JSON.stringify(data))
return data
})
async function fetchExpensiveData() {
// Simulated expensive operation
return { items: [1, 2, 3], generatedAt: new Date().toISOString() }
}
fastify.listen({ port: 3000 })
Best Practices for State Management in Fastify
- Prefer decoration over global variables. Always use
decorate,decorateRequest, anddecorateReplyinstead of Node.js global variables or module-level mutable state. This keeps your code testable and avoids naming collisions. - Always provide default values for request decorations. Since Fastify reuses request objects, failing to set defaults can cause data from a previous request to leak into the current one.
- Use encapsulation intentionally. Keep plugin state private by default and only use
fastify-pluginwhen you genuinely need to share state with the parent instance. - Initialize async state in hooks. Use
onReadyfor application-level async initialization andonRequestorpreHandlerfor request-level async state setup. - Clean up resources on shutdown. Use the
onClosehook to close database connections, Redis clients, and other stateful resources to prevent memory leaks and hanging processes. - Use TypeScript declarations for type safety. Extend Fastify's interfaces to declare your decorated properties, ensuring compile-time checking of all state access.
- Avoid storing large objects in request state. Request decorations should hold lightweight contextual data. Store large payloads in databases or caches and reference them by ID.
- Consider AsyncLocalStorage for deep call chains. If you find yourself passing the request object through many layers, AsyncLocalStorage can simplify your code, but measure the performance impact first.
- Use Redis or similar for distributed state. If your application runs multiple instances behind a load balancer, in-memory state will not be shared. Use an external store for any state that needs to be consistent across instances.
Conclusion
State management in Fastify is flexible and well-structured, thanks to the framework's decoration system and plugin encapsulation model. By using decorate for application-level singletons, decorateRequest for request-scoped data, and plugins for encapsulated or shared state, you can build applications that are clean, testable, and performant. For more advanced needs, tools like AsyncLocalStorage, Redis, and session plugins extend these foundations to handle deep call chains, distributed deployments, and user sessions. The key is to match the state management mechanism to the scope and lifetime of the data you are working with, and to follow consistent patterns across your codebase so that state is always predictable and easy to reason about.