State Management in Hapi: Patterns and Libraries
State management is one of the most important architectural decisions you will make when building a web application. In Hapi.js, the framework provides several built-in mechanisms for storing and sharing state across requests, plugins, and the server lifecycle. Unlike some other Node.js frameworks that leave state handling entirely to the developer, Hapi offers a structured approach through server.app, request.app, server methods, and a rich ecosystem of plugins. This tutorial walks you through the core patterns, practical implementations, and best practices for managing state effectively in a Hapi application.
What Is State Management in Hapi?
In the context of a Hapi application, "state" refers to any data that needs to be stored, shared, or persisted during the lifetime of the server or across the handling of individual HTTP requests. This includes configuration values, database connections, cached computations, user session data, and shared application context. Hapi distinguishes between several scopes of state:
- Server-level state โ data available to the entire application for the lifetime of the server process.
- Request-level state โ data scoped to a single HTTP request and its lifecycle.
- Cached state โ computed results stored for reuse, managed through server methods and caching plugins.
- Session state โ per-user data persisted across multiple requests, typically via cookies.
- Plugin-level state โ data namespaced within a specific plugin's context.
Understanding which scope your data belongs to is the first step toward a clean and maintainable architecture.
Why State Management Matters
Poor state management leads to a host of problems: memory leaks, race conditions, tightly coupled modules, and code that is difficult to test. In a Hapi application, where plugins and route handlers are meant to be modular and reusable, ad-hoc global variables undermine the framework's design philosophy. By using Hapi's built-in state containers, you gain predictable scoping, easier testing, better plugin isolation, and a clear contract for how data flows through your application. Additionally, leveraging Hapi's caching layer can dramatically improve performance by avoiding redundant computations or database queries.
Server-Level State with server.app
The simplest form of state in Hapi is the server.app object. This is a plain JavaScript object attached to the server instance that you can use to store any application-wide data. It is initialized automatically by Hapi and is accessible from any route handler, plugin, or extension point that has access to the server object.
const Hapi = require('@hapi/hapi');
const init = async () => {
const server = Hapi.server({
port: 3000,
host: 'localhost'
});
// Store application-level state
server.app.startTime = Date.now();
server.app.config = {
maxConnections: 100,
environment: process.env.NODE_ENV || 'development'
};
server.route({
method: 'GET',
path: '/uptime',
handler: (request, h) => {
const uptime = Date.now() - request.server.app.startTime;
return { uptimeMs: uptime };
}
});
await server.start();
console.log('Server running on %s', server.info.uri);
};
init();
In this example, server.app.startTime and server.app.config are available to every handler. This pattern is ideal for storing database connection pools, third-party client instances, or configuration loaded at startup.
Storing Database Connections
A common use case for server.app is holding a database connection pool that should be created once and reused across all requests.
const Hapi = require('@hapi/hapi');
const { Pool } = require('pg');
const init = async () => {
const server = Hapi.server({ port: 3000, host: 'localhost' });
// Create the pool once at startup
server.app.db = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20
});
server.route({
method: 'GET',
path: '/users/{id}',
handler: async (request, h) => {
const { id } = request.params;
const result = await request.server.app.db.query(
'SELECT * FROM users WHERE id = $1',
[id]
);
return result.rows[0] || h.response({ error: 'Not found' }).code(404);
}
});
// Clean up on stop
server.events.on('stop', async () => {
await server.app.db.end();
});
await server.start();
console.log('Server running on %s', server.info.uri);
};
init();
Notice the stop event listener that gracefully closes the connection pool. Always clean up resources stored on server.app when the server shuts down.
Request-Level State with request.app
While server.app is shared across all requests, request.app is scoped to a single HTTP request. Hapi creates a fresh request.app object for every incoming request, making it the perfect place to store data that should not leak between concurrent requests. This is especially useful for passing data between request lifecycle extensions and the final route handler.
const Hapi = require('@hapi/hapi');
const init = async () => {
const server = Hapi.server({ port: 3000, host: 'localhost' });
// An extension that attaches request-scoped state
server.ext('onPreHandler', (request, h) => {
request.app.requestId = request.headers['x-request-id'] || crypto.randomUUID();
request.app.startTime = Date.now();
return h.continue;
});
server.route({
method: 'GET',
path: '/profile',
handler: (request, h) => {
const elapsed = Date.now() - request.app.startTime;
return {
requestId: request.app.requestId,
processingTimeMs: elapsed,
user: 'alice'
};
}
});
await server.start();
console.log('Server running on %s', server.info.uri);
};
init();
Here, the onPreHandler extension populates request.app with a unique request ID and a start timestamp. The route handler then reads these values without needing to recompute them. Because request.app is isolated per request, there is no risk of one request overwriting another's data.
Authentication Context on request.app
Another common pattern is storing authentication or authorization context on request.app after a custom auth scheme runs. This keeps the handler clean and decoupled from auth logic.
server.ext('onPreHandler', (request, h) => {
const token = request.headers.authorization;
if (token) {
// In a real app, verify and decode the token
request.app.user = { id: 42, role: 'admin', name: 'Alice' };
}
return h.continue;
});
server.route({
method: 'GET',
path: '/dashboard',
handler: (request, h) => {
if (!request.app.user) {
return h.response({ error: 'Unauthorized' }).code(401);
}
return { message: `Welcome, ${request.app.user.name}` };
}
});
Server Methods for Cached State
Hapi's server methods provide a powerful way to encapsulate logic and optionally cache its results. A server method is a function registered on the server that can be called from any handler. When you add caching configuration, Hapi automatically stores the return value and serves cached results for subsequent calls with the same arguments. This is one of Hapi's most distinctive state management features.
Registering a Basic Server Method
const Hapi = require('@hapi/hapi');
const init = async () => {
const server = Hapi.server({ port: 3000, host: 'localhost' });
server.method('getUserById', async (id) => {
// Simulate a database lookup
const users = {
1: { id: 1, name: 'Alice', email: 'alice@example.com' },
2: { id: 2, name: 'Bob', email: 'bob@example.com' }
};
return users[id] || null;
});
server.route({
method: 'GET',
path: '/users/{id}',
handler: async (request, h) => {
const user = await server.methods.getUserById(request.params.id);
if (!user) {
return h.response({ error: 'Not found' }).code(404);
}
return user;
}
});
await server.start();
console.log('Server running on %s', server.info.uri);
};
init();
Adding Caching to Server Methods
To cache the results, pass an options object with a cache property. Hapi uses @hapi/catbox under the hood, which supports in-memory, Redis, and other backends.
const Hapi = require('@hapi/hapi');
const init = async () => {
const server = Hapi.server({
port: 3000,
host: 'localhost',
cache: [
{
name: 'memoryCache',
provider: {
constructor: require('@hapi/catbox-memory'),
options: { maxByteSize: 104857600 } // 100 MB
}
}
]
});
// Register a cached server method
server.method('getPopularProducts', async () => {
console.log('Fetching popular products from database...');
// Simulate expensive query
await new Promise(resolve => setTimeout(resolve, 500));
return [
{ id: 1, name: 'Widget', price: 9.99 },
{ id: 2, name: 'Gadget', price: 19.99 },
{ id: 3, name: 'Gizmo', price: 14.99 }
];
}, {
cache: {
cache: 'memoryCache',
expiresIn: 60 * 1000, // Cache for 60 seconds
generateTimeout: 2000 // Wait up to 2s for generation
}
});
server.route({
method: 'GET',
path: '/products/popular',
handler: async (request, h) => {
const products = await server.methods.getPopularProducts();
return { products, cached: true };
}
});
await server.start();
console.log('Server running on %s', server.info.uri);
};
init();
The first request to /products/popular will take approximately 500 milliseconds as the method executes the simulated query. Subsequent requests within the 60-second cache window will return instantly from the cache. The console log message will only appear on cache misses, confirming that the underlying function is not being called again.
Cached Methods with Arguments
Server methods can accept arguments, and Hapi will cache results per unique set of arguments. You must provide a generateKey function to produce a unique cache key string from the arguments.
server.method('getProductById', async (id) => {
console.log(`Fetching product ${id} from database...`);
await new Promise(resolve => setTimeout(resolve, 200));
return { id, name: `Product ${id}`, price: Math.random() * 100 };
}, {
cache: {
cache: 'memoryCache',
expiresIn: 30 * 1000,
generateTimeout: 1000
},
generateKey: (id) => `product:${id}`
});
server.route({
method: 'GET',
path: '/products/{id}',
handler: async (request, h) => {
const product = await server.methods.getProductById(request.params.id);
return product;
}
});
Each unique id value gets its own cache entry. Requesting /products/5 and /products/6 will generate separate cache keys and store independent results.
Session State with @hapi/yar
For per-user state that persists across multiple requests, Hapi offers the @hapi/yar plugin. Yar manages server-side sessions using cookies, storing session data either in memory, a cache, or a custom store. This is the standard way to handle user login state, shopping carts, and other per-user data in Hapi.
Setting Up Yar
const Hapi = require('@hapi/hapi');
const Yar = require('@hapi/yar');
const init = async () => {
const server = Hapi.server({
port: 3000,
host: 'localhost',
routes: {
cors: {
origin: ['*'],
credentials: true
}
}
});
await server.register({
plugin: Yar,
options: {
storeBlank: false,
cookieOptions: {
password: 'a-very-long-and-secure-password-at-least-32-chars',
isSecure: false, // Set to true in production with HTTPS
isHttpOnly: true,
isSameSite: 'Lax'
}
}
});
// Store data in the session
server.route({
method: 'POST',
path: '/login',
handler: (request, h) => {
const { username } = request.payload;
request.yar.set('user', { username, loggedInAt: Date.now() });
return { message: 'Logged in successfully' };
}
});
// Read data from the session
server.route({
method: 'GET',
path: '/me',
handler: (request, h) => {
const user = request.yar.get('user');
if (!user) {
return h.response({ error: 'Not authenticated' }).code(401);
}
return { user };
}
});
// Clear session data
server.route({
method: 'POST',
path: '/logout',
handler: (request, h) => {
request.yar.clear('user');
return { message: 'Logged out successfully' };
}
});
// Reset the entire session
server.route({
method: 'POST',
path: '/reset',
handler: (request, h) => {
request.yar.reset();
return { message: 'Session reset' };
}
});
await server.start();
console.log('Server running on %s', server.info.uri);
};
init();
The request.yar object provides set, get, clear, and reset methods. The session cookie is automatically sent to the client and returned on subsequent requests. The password option encrypts the cookie contents, so sensitive data is protected even though it is stored client-side.
Using a Custom Session Store
By default, Yar stores session data in the cookie itself. For larger session payloads, you can configure a server-side store using Hapi's caching infrastructure.
await server.register({
plugin: Yar,
options: {
storeBlank: false,
maxCookieSize: 0, // Force server-side storage
cache: {
cache: 'memoryCache',
expiresIn: 24 * 60 * 60 * 1000 // 24 hours
},
cookieOptions: {
password: 'a-very-long-and-secure-password-at-least-32-chars',
isSecure: false
}
}
});
Setting maxCookieSize to 0 forces Yar to store all session data server-side, with only a session ID sent to the client in the cookie. This is the recommended approach for production applications with significant session data.
Plugin-Level State
Hapi plugins can maintain their own state, which is useful for building reusable modules. Each plugin receives its own context during registration, and you can attach state to the plugin's server instance or use closures to encapsulate private data.
const Hapi = require('@hapi/hapi');
// A plugin that manages its own internal state
const counterPlugin = {
name: 'counter',
version: '1.0.0',
register: async (server, options) => {
// Private state encapsulated in the plugin closure
let counters = {};
server.route({
method: 'POST',
path: '/counter/{key}/increment',
handler: (request, h) => {
const { key } = request.params;
counters[key] = (counters[key] || 0) + 1;
return { key, value: counters[key] };
}
});
server.route({
method: 'GET',
path: '/counter/{key}',
handler: (request, h) => {
const { key } = request.params;
return { key, value: counters[key] || 0 };
}
});
// Expose a method for other plugins to use
server.expose('getCount', (key) => counters[key] || 0);
server.expose('reset', (key) => { delete counters[key]; });
}
};
const init = async () => {
const server = Hapi.server({ port: 3000, host: 'localhost' });
await server.register(counterPlugin);
// Access exposed plugin methods
server.route({
method: 'GET',
path: '/counter/{key}/raw',
handler: (request, h) => {
const count = server.plugins.counter.getCount(request.params.key);
return { rawCount: count };
}
});
await server.start();
console.log('Server running on %s', server.info.uri);
};
init();
The server.expose method makes plugin functionality available to other parts of the application through server.plugins.<pluginName>. The internal counters object remains private to the plugin, enforcing encapsulation.
Caching with @hapi/catbox
For more advanced caching needs beyond server methods, Hapi provides @hapi/catbox, a multi-tier caching library. Catbox supports multiple backends including memory, Redis, and MongoDB. You can use it directly for fine-grained control over cached state.
Using Catbox with Redis
const Hapi = require('@hapi/hapi');
const CatboxRedis = require('@hapi/catbox-redis');
const init = async () => {
const server = Hapi.server({
port: 3000,
host: 'localhost',
cache: [
{
name: 'redisCache',
provider: {
constructor: CatboxRedis,
options: {
host: '127.0.0.1',
port: 6379,
partition: 'myapp'
}
}
}
]
});
await server.start();
// Access the cache directly
const cache = server.cache({ cache: 'redisCache', segment: 'weather' });
// Set a value
await cache.set('london', { temp: 15, condition: 'rainy' }, 600 * 1000);
// Get a value
const weather = await cache.get('london');
console.log('Cached weather:', weather);
console.log('Server running on %s', server.info.uri);
};
init();
The segment option namespaces cached items within a partition, preventing collisions between different parts of your application. This is particularly useful when multiple features share the same Redis instance.
Combining Catbox with Server Methods
You can direct specific server methods to use a Redis-backed cache while others use in-memory storage, giving you precise control over cache lifetimes and persistence.
// In-memory cache for frequently accessed, easily recomputed data
server.method('getAppConfig', async () => {
return { features: { beta: true }, limits: { maxItems: 50 } };
}, {
cache: {
cache: 'memoryCache',
expiresIn: 10 * 1000
}
});
// Redis cache for data that should survive server restarts
server.method('getUserProfile', async (userId) => {
return await fetchProfileFromDatabase(userId);
}, {
cache: {
cache: 'redisCache',
expiresIn: 30 * 60 * 1000, // 30 minutes
staleIn: 25 * 60 * 1000, // Serve stale while regenerating after 25 min
generateTimeout: 5000
},
generateKey: (userId) => `profile:${userId}`
});
The staleIn option allows Hapi to serve slightly stale data while asynchronously regenerating a fresh value, reducing latency for end users.
Best Practices for State Management in Hapi
Choose the Right Scope
Always use the narrowest scope that satisfies your needs. If data is only relevant to a single request, use request.app. If it must persist across requests for a single user, use a session via Yar. If it is truly global, use server.app. Avoid stuffing everything into server.app just because it is convenient.
Initialize State During Startup
Set up all server-level state before calling server.start(). This includes database pools, cache connections, and configuration objects. Initializing state after the server starts can lead to race conditions where early requests access uninitialized values.
const init = async () => {
const server = Hapi.server({ port: 3000, host: 'localhost' });
// Initialize all state before starting
server.app.db = createDbPool();
server.app.redis = createRedisClient();
server.app.featureFlags = await loadFeatureFlags();
await server.register(Yar, yarOptions);
await server.register(otherPlugins);
// Register server methods with caching
registerCachedMethods(server);
// Now start
await server.start();
console.log('Server running on %s', server.info.uri);
};
Use Server Methods for Reusable Logic
Instead of duplicating data-fetching logic across handlers, encapsulate it in server methods. This makes the logic testable, cacheable, and consistent. If the underlying data source changes, you only update one place.
Secure Session Cookies
When using Yar in production, always set isSecure: true to ensure cookies are only transmitted over HTTPS. Use a strong, unique password of at least 32 characters for cookie encryption. Consider setting isSameSite: 'Strict' or 'Lax' to protect against CSRF attacks.
Implement Cache Invalidation
Cached state can become stale. Implement invalidation strategies using cache.drop() for server methods or direct cache operations when underlying data changes.
server.route({
method: 'PUT',
path: '/products/{id}',
handler: async (request, h) => {
const { id } = request.params;
const updated = await updateProductInDb(id, request.payload);
// Invalidate the cached server method result
await server.methods.getProductById.cache.drop(id);
return updated;
}
});
Avoid Mutable Shared State Without Synchronization
If you store mutable objects on server.app, be aware that multiple concurrent requests may read or modify them simultaneously. For simple counters or flags, this is usually fine in Node.js's single-threaded event loop. However, if your state involves asynchronous read-modify-write cycles, use proper synchronization or atomic operations to prevent race conditions.
Test State in Isolation
Hapi's server.inject method lets you simulate requests without starting a network server. Use it to test state management logic in isolation.
const Hapi = require('@hapi/hapi');
const assert = require('assert');
const setupServer = async () => {
const server = Hapi.server({ port: 0 });
server.app.counter = 0;
server.route({
method: 'POST',
path: '/increment',
handler: (request, h) => {
request.server.app.counter++;
return { count: request.server.app.counter };
}
});
return server;
};
const runTests = async () => {
const server = await setupServer();
const res1 = await server.inject({ method: 'POST', url: '/increment' });
assert.strictEqual(res1.result.count, 1);
const res2 = await server.inject({ method: 'POST', url: '/increment' });
assert.strictEqual(res2.result.count, 2);
console.log('All tests passed');
};
runTests();
Use Plugins for Encapsulation
When building large applications, group related state and routes into plugins. This prevents the global server namespace from becoming cluttered and makes modules independently testable and reusable across projects.
Conclusion
State management in Hapi is intentionally structured, offering distinct containers for different scopes of data. By using server.app for application-wide resources, request.app for per-request context, server methods with caching for computed results, Yar for user sessions, and plugins for encapsulated modules, you can build applications that are predictable, performant, and maintainable. The key is to match each piece of state to the appropriate container, initialize it at the right time, and clean it up when the server shuts down. Following these patterns will help you avoid the common pitfalls of unstructured state management and let you take full advantage of Hapi's thoughtful design.