โ† Back to DevBytes

State Management in Fastify: Patterns and Libraries

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:

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

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.

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