Introduction to State Management in Express
State management is one of the most misunderstood concepts in Express.js development. While Express itself is stateless by design, real-world applications inevitably need to track, share, and persist data across requests, middleware, and route handlers. Whether you're caching user sessions, sharing configuration across modules, or coordinating background jobs, understanding how to manage state effectively is critical for building robust, scalable Node.js backends.
In this tutorial, we'll explore what state management means in the context of Express, why it matters, the common patterns developers use, and the libraries that can help you avoid reinventing the wheel. By the end, you'll have a clear mental model for choosing the right approach for your application's needs.
What Is State in Express?
In Express, "state" refers to any data that persists beyond a single function call and needs to be accessed by multiple parts of your application. Unlike frontend frameworks like React or Vue, where state management libraries are ubiquitous, Express applications often handle state implicitly โ sometimes dangerously so.
There are several categories of state you'll encounter:
- Request state โ data scoped to a single HTTP request, such as authenticated user info or request IDs.
- Session state โ data persisted across multiple requests from the same client, typically stored in cookies or server-side stores.
- Application state โ global configuration, database connections, or feature flags shared across the entire process.
- Shared mutable state โ in-memory caches, rate limit counters, or pub/sub channels accessed concurrently.
Each category requires different tools and patterns. Misclassifying state is a common source of bugs, memory leaks, and scaling problems.
Why State Management Matters
Express is built on a request-response cycle: each request is handled independently, and the framework makes no guarantees about shared memory between requests. This design is intentional โ it promotes horizontal scaling and simplifies reasoning about individual requests. However, it also means developers must be deliberate about how they store and access shared data.
Poor state management leads to several well-known problems:
- Memory leaks when data accumulates in global variables without eviction.
- Race conditions when multiple async operations mutate shared structures.
- Scaling failures when state stored in process memory doesn't survive a restart or isn't available across instances.
- Testing difficulties when modules depend on hidden global state.
A thoughtful state management strategy addresses these concerns while keeping your codebase maintainable.
Pattern 1: Request-Scoped State with res.locals
The simplest and safest form of state in Express is request-scoped state. Express provides the res.locals object specifically for this purpose. Any property you attach to res.locals is available to all subsequent middleware and view templates during that request's lifecycle, and it's automatically discarded when the response is sent.
const express = require('express');
const app = express();
// Middleware that attaches request-scoped state
app.use((req, res, next) => {
res.locals.requestId = crypto.randomUUID();
res.locals.startTime = Date.now();
next();
});
// Authentication middleware populates user info
app.use((req, res, next) => {
const token = req.headers.authorization;
if (token) {
// In practice, verify the token
res.locals.user = { id: 42, name: 'Ada Lovelace' };
}
next();
});
// Route handler accesses the accumulated state
app.get('/profile', (req, res) => {
const { user, requestId, startTime } = res.locals;
if (!user) {
return res.status(401).json({ error: 'Unauthorized', requestId });
}
res.json({
user,
requestId,
durationMs: Date.now() - startTime
});
});
app.listen(3000);
This pattern is ideal because it avoids global mutation entirely. Each request gets its own isolated namespace, and you never have to worry about one request corrupting another's data. Use res.locals whenever you need to pass data down the middleware chain.
Pattern 2: Application State with app.locals
For data that should be shared across all requests and available for the lifetime of the application, Express offers app.locals. This is the appropriate place to store things like configuration values, database connection pools, or service clients that are initialized once at startup.
const express = require('express');
const { Pool } = require('pg');
const app = express();
// Initialize shared resources once at startup
app.locals.db = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20
});
app.locals.config = {
maxUploadSize: 10 * 1024 * 1024,
featureFlags: {
newDashboard: true,
betaApi: false
}
};
app.locals.cache = new Map();
// Access shared state in any route
app.get('/users/:id', async (req, res) => {
const { db, cache } = app.locals;
const { id } = req.params;
if (cache.has(`user:${id}`)) {
return res.json(cache.get(`user:${id}`));
}
const result = await db.query('SELECT * FROM users WHERE id = $1', [id]);
cache.set(`user:${id}`, result.rows[0]);
res.json(result.rows[0]);
});
app.listen(3000);
While app.locals is convenient, be cautious about storing mutable state here. If you're running multiple process instances behind a load balancer, data in app.locals won't be shared between them. For truly shared state across instances, you'll need an external store like Redis.
Pattern 3: Session State with express-session
When you need to persist data across multiple requests from the same client โ such as login status or shopping cart contents โ you need session management. The express-session middleware is the standard solution. It generates a session ID, stores it in a cookie, and lets you attach arbitrary data to req.session.
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const app = express();
const redisClient = createClient({ url: 'redis://localhost:6379' });
redisClient.connect().catch(console.error);
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 1000 * 60 * 60 * 24 // 24 hours
}
}));
app.post('/login', (req, res) => {
// Validate credentials here
req.session.userId = 42;
req.session.role = 'admin';
res.json({ message: 'Logged in successfully' });
});
app.get('/dashboard', (req, res) => {
if (!req.session.userId) {
return res.status(401).json({ error: 'Please log in' });
}
res.json({
userId: req.session.userId,
role: req.session.role
});
});
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'Logout failed' });
}
res.clearCookie('connect.sid');
res.json({ message: 'Logged out' });
});
});
app.listen(3000);
Notice that we're using Redis as the session store. The default MemoryStore is fine for development but leaks memory in production and doesn't work across multiple instances. Always use a dedicated store like connect-redis, connect-mongo, or connect-pg-simple in production.
Pattern 4: Dependency Injection for Testable State
As applications grow, relying on app.locals or global singletons makes testing difficult. A more maintainable pattern is dependency injection: create your stateful services as modules, instantiate them once, and pass them explicitly to the routes that need them.
// db.js
const { Pool } = require('pg');
let pool = null;
function createDb(config) {
if (!pool) {
pool = new Pool(config);
}
return pool;
}
module.exports = { createDb };
// cache.js
class Cache {
constructor(ttlMs = 60000) {
this.store = new Map();
this.ttlMs = ttlMs;
}
get(key) {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.value;
}
set(key, value) {
this.store.set(key, {
value,
expiresAt: Date.now() + this.ttlMs
});
}
}
module.exports = Cache;
// routes/users.js
function createUserRouter(db, cache) {
const router = require('express').Router();
router.get('/:id', async (req, res) => {
const { id } = req.params;
const cached = cache.get(`user:${id}`);
if (cached) return res.json(cached);
const result = await db.query('SELECT * FROM users WHERE id = $1', [id]);
cache.set(`user:${id}`, result.rows[0]);
res.json(result.rows[0]);
});
return router;
}
module.exports = createUserRouter;
// app.js
const express = require('express');
const { createDb } = require('./db');
const Cache = require('./cache');
const createUserRouter = require('./routes/users');
const app = express();
const db = createDb({ connectionString: process.env.DATABASE_URL });
const cache = new Cache(30000);
app.use('/users', createUserRouter(db, cache));
app.listen(3000);
This pattern makes each component's dependencies explicit. In tests, you can pass mock implementations of db and cache without touching global state or environment variables.
Pattern 5: Async Local Storage for Implicit Context
Sometimes you need request-scoped data to be accessible deep in your call stack without passing it through every function parameter. Node.js provides AsyncLocalStorage for exactly this scenario. It creates a context that propagates through async operations automatically, similar to thread-local storage in other languages.
const express = require('express');
const { AsyncLocalStorage } = require('async_hooks');
const app = express();
const als = new AsyncLocalStorage();
// Middleware that establishes request context
app.use((req, res, next) => {
const context = {
requestId: crypto.randomUUID(),
user: null,
startTime: Date.now()
};
als.run(context, () => next());
});
// A deeply nested service function can access context without parameters
function logWithContext(message) {
const ctx = als.getStore();
console.log(`[${ctx?.requestId}] ${message}`);
}
async function fetchUserPreferences(userId) {
logWithContext(`Fetching preferences for user ${userId}`);
// ... database call
return { theme: 'dark', notifications: true };
}
app.get('/preferences', async (req, res) => {
const ctx = als.getStore();
ctx.user = { id: 42 };
const prefs = await fetchUserPreferences(42);
logWithContext('Preferences retrieved');
res.json({
...prefs,
requestId: ctx.requestId,
durationMs: Date.now() - ctx.startTime
});
});
app.listen(3000);
Many popular libraries now use AsyncLocalStorage internally. For example, the cls-hooked package and newer versions of NestJS's request-scoped providers build on this primitive. It's powerful, but use it judiciously โ implicit context can make code harder to follow if overused.
Libraries for State Management
express-session
The de facto standard for session management in Express. We covered it above, but it's worth reiterating: always pair it with a production-grade store. Popular options include connect-redis, connect-mongo, and connect-pg-simple.
node-cache
For simple in-memory caching with TTL support, node-cache is a lightweight choice. It handles expiration, statistics, and even event hooks for when keys are set or deleted.
const NodeCache = require('node-cache');
const cache = new NodeCache({ stdTTL: 60, checkperiod: 30 });
app.get('/products/:id', async (req, res) => {
const key = `product:${req.params.id}`;
let product = cache.get(key);
if (!product) {
product = await fetchProductFromDb(req.params.id);
cache.set(key, product);
}
res.json(product);
});
rate-limiter-flexible
For rate limiting โ a form of shared mutable state โ rate-limiter-flexible supports both in-memory and Redis-backed stores, making it suitable for single-instance and distributed deployments alike.
const { RateLimiterMemory, RateLimiterRedis } = require('rate-limiter-flexible');
const redis = require('redis');
const limiter = process.env.REDIS_URL
? new RateLimiterRedis({
storeClient: redis.createClient({ url: process.env.REDIS_URL }),
keyPrefix: 'middleware',
points: 100,
duration: 60
})
: new RateLimiterMemory({
points: 100,
duration: 60
});
app.use(async (req, res, next) => {
try {
await limiter.consume(req.ip);
next();
} catch {
res.status(429).json({ error: 'Too many requests' });
}
});
Bull for Job Queues
Background job processing requires durable state that survives process restarts. Bull (and its successor BullMQ) uses Redis to manage job queues, retries, and scheduling.
const Queue = require('bull');
const emailQueue = new Queue('emails', 'redis://localhost:6379');
app.post('/send-welcome', async (req, res) => {
await emailQueue.add('welcome', {
email: req.body.email,
userId: req.body.userId
}, {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 }
});
res.json({ message: 'Welcome email queued' });
});
emailQueue.process('welcome', async (job) => {
await sendEmail(job.data.email, 'Welcome!');
});
Best Practices
- Prefer request-scoped state. Use
res.localsorAsyncLocalStoragebefore reaching for global state. The narrower the scope, the fewer bugs you'll have. - Never use the default MemoryStore in production. It's explicitly documented as not suitable for production. It leaks memory and doesn't scale.
- Externalize state for horizontal scaling. If you run more than one instance, any state that needs to be consistent must live in Redis, a database, or another shared store โ not in process memory.
- Use dependency injection for testability. Avoid importing singletons directly in route handlers. Pass dependencies through factory functions so you can substitute mocks in tests.
- Set TTLs on all caches. Unbounded caches are a guaranteed memory leak. Every cache entry should have an expiration, even if it's long.
- Handle concurrency carefully. Node.js is single-threaded, but async operations can interleave. If you read-modify-write shared state, use atomic operations or locks where appropriate.
- Log with context. Attach a request ID to every log entry using
res.localsorAsyncLocalStorageso you can trace a single request across your entire system. - Validate session secrets. Use long, random secrets for
express-session, rotate them periodically, and never commit them to version control.
Conclusion
State management in Express is less about adopting a single library and more about understanding the lifecycle of your data. By matching each piece of state to the right scope โ request, session, application, or external store โ you can build applications that are predictable, testable, and ready to scale. Start with res.locals for request data, graduate to express-session with Redis for client persistence, use dependency injection for shared services, and reach for AsyncLocalStorage when you need implicit context propagation. With these patterns in your toolkit, you'll be well-equipped to handle whatever state management challenges your Express application encounters.