State Management in RESTify: Patterns and Libraries
RESTify is a lightweight Node.js framework purpose-built for building correct, observable REST web services. Because REST is fundamentally stateless, the term "state management" in a RESTify context can feel paradoxical at first. However, every real-world API must deal with several flavors of state: request-scoped state, server-level configuration, cached responses, session data, and distributed state shared across instances. This tutorial walks through the patterns and libraries that make state management in RESTify clean, predictable, and scalable.
Why State Management Matters in RESTify
Even though RESTify embraces statelessness at the protocol level, your application still needs to manage state at multiple layers. Poor state handling leads to subtle bugs: leaked request data between users, race conditions in shared caches, inconsistent configuration across worker processes, and unpredictable behavior when scaling horizontally behind a load balancer.
The key insight is that state in a RESTify application falls into distinct categories, each with its own appropriate storage strategy:
- Request-scoped state โ data that lives only for the duration of a single request/response cycle.
- Server-level state โ configuration, connection pools, and singletons shared across all requests on one process.
- Cached state โ responses or computed values stored to avoid redundant work.
- Session state โ user identity and context persisted across multiple requests.
- Distributed state โ data shared across multiple server instances, typically through an external store.
Conflating these categories is the most common mistake. Storing request-scoped data on the server object, for example, causes cross-request contamination. Storing session data in process memory prevents horizontal scaling. The patterns below address each category explicitly.
Request-Scoped State
The cleanest place to store per-request data is on the request object itself. RESTify's req object is created fresh for every incoming request, making it a safe container for transient values like authenticated user info, trace IDs, or parsed parameters.
Using req.locals
A widely adopted convention is to attach a locals object to req early in the middleware chain. This mirrors the pattern used by Express and keeps your state namespace predictable.
const restify = require('restify');
const server = restify.createServer({ name: 'state-demo' });
server.use(restify.plugins.bodyParser());
// Initialize request-scoped state
server.use((req, res, next) => {
req.locals = {
requestId: req.headers['x-request-id'] || crypto.randomUUID(),
startedAt: Date.now(),
user: null
};
res.setHeader('x-request-id', req.locals.requestId);
next();
});
// Authentication middleware populates state
server.use((req, res, next) => {
const token = req.headers.authorization;
if (token) {
req.locals.user = verifyToken(token); // your auth logic
}
next();
});
server.get('/profile', (req, res, next) => {
if (!req.locals.user) {
return next(new restify.UnauthorizedError('Not authenticated'));
}
res.send(200, {
requestId: req.locals.requestId,
user: req.locals.user
});
next();
});
server.listen(8080, () => {
console.log('%s listening at %s', server.name, server.url);
});
This pattern is safe because req is garbage collected after the response is sent. There is no risk of one request reading another request's data.
Avoiding Common Request-State Pitfalls
Never store request-scoped data on the server object or in module-level variables. The following anti-pattern causes data leakage under concurrent load:
// DANGEROUS: shared mutable state across requests
let currentUser = null;
server.use((req, res, next) => {
currentUser = req.headers['x-user-id']; // overwritten by concurrent requests!
next();
});
server.get('/me', (req, res, next) => {
res.send(200, { user: currentUser }); // may return a different user's ID
next();
});
Under concurrent requests, currentUser will be overwritten by whichever request arrives last, producing incorrect responses. Always use req.locals instead.
Server-Level State
Server-level state includes configuration values, database connection pools, and service clients that are initialized once at startup and shared across all requests. RESTify does not prescribe a specific pattern for this, but attaching these resources to the server object is a pragmatic approach.
Attaching Services to the Server
const restify = require('restify');
const { Pool } = require('pg');
const redis = require('redis');
const server = restify.createServer();
// Initialize shared resources once
server.db = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20
});
server.redis = redis.createClient({
url: process.env.REDIS_URL
});
server.redis.on('error', (err) => console.error('Redis error:', err));
server.redis.connect().then(() => {
server.use(restify.plugins.bodyParser());
server.get('/users/:id', async (req, res, next) => {
try {
const result = await server.db.query(
'SELECT id, name, email FROM users WHERE id = $1',
[req.params.id]
);
if (result.rows.length === 0) {
return next(new restify.NotFoundError('User not found'));
}
res.send(200, result.rows[0]);
next();
} catch (err) {
next(err);
}
});
server.listen(8080);
});
Because these resources are stateless from the request perspective โ a connection pool hands out fresh connections per query, and Redis clients handle multiplexing internally โ sharing them across requests is safe and efficient.
Using a Dependency Injection Container
For larger applications, attaching everything to server becomes unwieldy. A dependency injection container keeps your wiring explicit and testable. Libraries like awilix integrate well with RESTify.
const restify = require('restify');
const awilix = require('awilix');
// Services
class UserService {
constructor({ db }) {
this.db = db;
}
async findById(id) {
const result = await this.db.query('SELECT * FROM users WHERE id = $1', [id]);
return result.rows[0] || null;
}
}
class EmailService {
constructor({ logger }) {
this.logger = logger;
}
send(to, subject, body) {
this.logger.info(`Sending email to ${to}: ${subject}`);
}
}
// Container setup
const container = awilix.createContainer({
injectionMode: awilix.InjectionMode.PROXY
});
container.register({
db: awilix.asValue(/* your db pool */),
logger: awilix.asValue(console),
userService: awilix.asClass(UserService).singleton(),
emailService: awilix.asClass(EmailService).singleton()
});
// Wire into RESTify
const server = restify.createServer();
server.use(restify.plugins.bodyParser());
server.use((req, res, next) => {
req.container = container.createScope();
next();
});
server.get('/users/:id', async (req, res, next) => {
const userService = req.container.resolve('userService');
const user = await userService.findById(req.params.id);
if (!user) return next(new restify.NotFoundError('User not found'));
res.send(200, user);
next();
});
server.listen(8080);
Using createScope() per request lets you register request-specific dependencies (like the current user) without polluting the root container.
Caching State
Caching is the most performance-critical form of state in a REST API. RESTify ships with a built-in caching plugin, and several third-party libraries cover more advanced use cases.
Built-in HTTP Caching with restify.plugins.throttle
RESTify's throttle plugin manages rate-limiting state, which is a form of short-lived state keyed by client identifier.
const restify = require('restify');
const server = restify.createServer();
server.use(restify.plugins.throttle({
burst: 20, // max requests allowed in a burst
rate: 10, // sustained rate of requests per second
ip: true, // throttle by IP address
overrides: {
'127.0.0.1': { rate: 0, burst: 0 } // no limit for localhost
}
}));
server.get('/data', (req, res, next) => {
res.send(200, { message: 'throttled endpoint' });
next();
});
server.listen(8080);
By default, the throttle stores counters in memory. This works for a single instance but fails behind a load balancer. For distributed deployments, you need an external store.
In-Process Caching with node-cache
For caching computed values or database lookups within a single process, node-cache is simple and reliable.
const restify = require('restify');
const NodeCache = require('node-cache');
const server = restify.createServer();
const cache = new NodeCache({ stdTTL: 60, checkperiod: 30 });
server.get('/products/:id', async (req, res, next) => {
const cacheKey = `product:${req.params.id}`;
const cached = cache.get(cacheKey);
if (cached) {
res.setHeader('x-cache', 'HIT');
res.send(200, cached);
return next();
}
res.setHeader('x-cache', 'MISS');
const product = await fetchProductFromDatabase(req.params.id);
if (product) {
cache.set(cacheKey, product);
}
res.send(200, product);
next();
});
server.listen(8080);
Distributed Caching with Redis
When running multiple RESTify instances, Redis provides a shared cache that keeps all instances consistent. The redis client supports both simple key-value storage and structured data via JSON serialization.
const restify = require('restify');
const redis = require('redis');
const server = restify.createServer();
const cache = redis.createClient({ url: 'redis://localhost:6379' });
cache.on('error', (err) => console.error('Redis error:', err));
async function getCached(key, fetchFn, ttlSeconds = 60) {
const cached = await cache.get(key);
if (cached) return JSON.parse(cached);
const fresh = await fetchFn();
if (fresh !== null && fresh !== undefined) {
await cache.setEx(key, ttlSeconds, JSON.stringify(fresh));
}
return fresh;
}
server.get('/products/:id', async (req, res, next) => {
try {
const product = await getCached(
`product:${req.params.id}`,
() => fetchProductFromDatabase(req.params.id),
120
);
if (!product) {
return next(new restify.NotFoundError('Product not found'));
}
res.send(200, product);
next();
} catch (err) {
next(err);
}
});
cache.connect().then(() => server.listen(8080));
Session State
True REST APIs are stateless and should avoid server-side sessions, relying instead on tokens (JWT, opaque tokens) that carry all necessary context. However, some scenarios โ like step-up authentication or OAuth flows โ require temporary server-side state. For these cases, store session data in an external store rather than process memory.
Token-Based Authentication (Stateless)
const restify = require('restify');
const jwt = require('jsonwebtoken');
const server = restify.createServer();
const JWT_SECRET = process.env.JWT_SECRET || 'change-me';
server.use(restify.plugins.bodyParser());
server.post('/login', async (req, res, next) => {
const { username, password } = req.body;
const user = await authenticate(username, password);
if (!user) {
return next(new restify.UnauthorizedError('Invalid credentials'));
}
const token = jwt.sign(
{ userId: user.id, role: user.role },
JWT_SECRET,
{ expiresIn: '1h' }
);
res.send(200, { token });
next();
});
server.use((req, res, next) => {
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Bearer ')) {
return next();
}
try {
req.locals = req.locals || {};
req.locals.user = jwt.verify(auth.slice(7), JWT_SECRET);
} catch (err) {
// Invalid token โ leave user unset
}
next();
});
server.get('/dashboard', (req, res, next) => {
if (!req.locals || !req.locals.user) {
return next(new restify.UnauthorizedError('Authentication required'));
}
res.send(200, { message: `Welcome user ${req.locals.user.userId}` });
next();
});
server.listen(8080);
Redis-Backed Sessions for Special Cases
const crypto = require('crypto');
const redis = require('redis');
const sessionStore = redis.createClient({ url: 'redis://localhost:6379' });
async function createSession(userId, data, ttlSeconds = 300) {
const sessionId = crypto.randomUUID();
await sessionStore.setEx(
`session:${sessionId}`,
ttlSeconds,
JSON.stringify({ userId, ...data })
);
return sessionId;
}
async function getSession(sessionId) {
const raw = await sessionStore.get(`session:${sessionId}`);
return raw ? JSON.parse(raw) : null;
}
async function destroySession(sessionId) {
await sessionStore.del(`session:${sessionId}`);
}
This approach keeps your RESTify processes stateless while still supporting temporary server-side state when genuinely needed.
Distributed State Across Instances
When you scale RESTify to multiple instances behind a load balancer, any state stored in process memory becomes inconsistent. Each instance has its own copy, and updates on one instance are invisible to others. The solution is always to externalize shared state.
Pattern: Externalize All Cross-Request State
const restify = require('restify');
const redis = require('redis');
const server = restify.createServer();
const sharedState = redis.createClient({ url: 'redis://localhost:6379' });
// Counter endpoint โ works correctly across multiple instances
server.get('/visits', async (req, res, next) => {
try {
const count = await sharedState.incr('global:visits');
res.send(200, { visits: count });
next();
} catch (err) {
next(err);
}
});
sharedState.connect().then(() => server.listen(8080));
If you had stored this counter in a JavaScript variable, each instance would report its own independent count. Redis ensures all instances see the same value.
Pub/Sub for Cross-Instance Communication
Sometimes instances need to notify each other of events โ for example, invalidating a local cache when data changes. Redis pub/sub handles this elegantly.
const restify = require('restify');
const redis = require('redis');
const NodeCache = require('node-cache');
const server = restify.createServer();
const localCache = new NodeCache({ stdTTL: 300 });
const redisClient = redis.createClient({ url: 'redis://localhost:6379' });
const subscriber = redisClient.duplicate();
// Subscribe to cache invalidation events
subscriber.subscribe('cache:invalidate');
subscriber.on('message', (channel, message) => {
if (channel === 'cache:invalidate') {
localCache.del(message); // message contains the cache key to invalidate
console.log(`Invalidated local cache key: ${message}`);
}
});
server.put('/products/:id', async (req, res, next) => {
try {
await updateProductInDatabase(req.params.id, req.body);
// Invalidate cache on all instances
await redisClient.publish('cache:invalidate', `product:${req.params.id}`);
res.send(200, { updated: true });
next();
} catch (err) {
next(err);
}
});
Promise.all([redisClient.connect(), subscriber.connect()])
.then(() => server.listen(8080));
Best Practices
- Keep request state on req. Use
req.localsconsistently. Never store per-request data on the server object or in module-level variables. - Initialize shared resources once. Database pools, Redis clients, and HTTP clients should be created at startup, not per request. Attach them to
serveror a DI container. - Externalize state for horizontal scaling. Any state that must be consistent across instances belongs in Redis, a database, or another shared store โ never in process memory.
- Prefer stateless authentication. Use JWT or opaque tokens validated against an external store. Avoid in-memory session maps.
- Set TTLs on all cached data. Caches without expiration become stale data sources. Always specify a TTL, even if it is long.
- Handle cache failures gracefully. If Redis goes down, your API should degrade to slower but correct behavior, not crash. Wrap cache access in try/catch and fall back to the data source.
- Use structured logging with request IDs. Store a request ID in
req.localsand include it in every log line. This makes tracing state-related bugs across services far easier. - Test under concurrent load. State bugs often only appear when multiple requests overlap. Use tools like
autocannonorartilleryto stress-test endpoints that touch shared state. - Scope DI containers per request. When using dependency injection, create a child scope per request so request-specific dependencies do not leak.
Conclusion
State management in RESTify is less about a single library or plugin and more about matching the right storage strategy to the right category of state. Request-scoped data belongs on req.locals, shared resources belong on the server or in a DI container, cached data belongs in node-cache for single instances or Redis for distributed deployments, and session data should be externalized whenever possible. By respecting these boundaries, you keep your RESTify services stateless at the protocol level while still handling the real-world state requirements of authentication, caching, rate limiting, and horizontal scaling. The result is an API that is predictable under load, easy to reason about, and ready to scale from a single process to a fleet of instances without architectural rewrites.