Hapi Interview Questions and Answers
Last updated:
Check out 35 of the most common Hapi interview questions, then take an AI-powered practice interview
Q1How does hapi's configuration-centric design differ from Express middleware chains?
BasicFundamentals
Answer
Express is a middleware pipeline: you push functions onto a stack with app.use() and each one decides whether to call next(). Behaviour is therefore a function of registration order, and reading a route tells you almost nothing about what protects it. hapi inverts that. A route is a declarative object, and everything that applies to it lives in options: validate, auth, payload, cache, cors, timeout, response, pre, plugins, tags.
The framework, not your ordering, decides when each of those runs, because the request lifecycle is a fixed sequence of stages. Two practical consequences show up immediately in production. First, route order does not matter: hapi's router sorts paths by specificity at registration time, so /users/me always beats /users/{id} no matter which you registered first, and registering the same path twice throws at boot instead of silently shadowing.
Second, cross-cutting behaviour is added with server.ext() at a named lifecycle point rather than by inserting a function at the right index in an array. hapi also encapsulates: a plugin gets its own realm, so routes, extensions and decorations registered inside it do not leak outward unless you export them deliberately. Interviewers use this question to check whether you can articulate why a team would accept hapi's heavier configuration surface, and the honest answer is auditability. On a payments or identity service, being able to read one route object and know exactly what validates, authenticates and rate-limits it is worth more than the terseness of Express.
Key Points
- Routes are declarative objects; options carries validate, auth, payload, cache, cors
- No app.use() chain, so behaviour does not depend on registration order
- Router sorts by specificity; duplicate paths throw at startup, not at request time
- Cross-cutting logic attaches to named lifecycle points via server.ext()
- Plugins are encapsulated in realms rather than sharing one global stack
Q2How do you bootstrap a hapi server, and what does Hapi.server() actually configure?
BasicServer Setup
Answer
Hapi.server(options) builds a server object but does not bind a socket. The options you pass become the defaults for every route registered later, which is where most of the leverage is: routes.cors, routes.validate.options, routes.payload.maxBytes, routes.timeout, routes.security and routes.cache all cascade down and can be overridden per route. Other top-level keys include port, host, address, cache (catbox provisioning), state (cookie defaults), query (the query string parser), compression, debug and app (a free-form object exposed as server.app for shared state).
The canonical project layout exports init() and start() separately from the module that builds the server. Tests call init(), which runs onPreStart extensions and initialises caches without opening a port, while the process entry point calls start(). Always attach a process-level unhandledRejection handler that logs and exits, because a rejected promise inside a handler that escapes hapi's lifecycle will otherwise leave the process in an undefined state.
A second production detail: bind to 0.0.0.0 inside containers, not localhost, or your Kubernetes readiness probe will never reach the pod. Note that server.info.uri reflects the bound address, so log it on start to confirm what actually happened rather than what you configured.
'use strict';
const Hapi = require('@hapi/hapi');
const server = Hapi.server({
port: process.env.PORT || 3000,
host: '0.0.0.0',
routes: {
cors: { origin: ['https://app.example.in'] },
validate: { options: { abortEarly: false, stripUnknown: true } },
payload: { maxBytes: 2 * 1024 * 1024 },
timeout: { server: 15000 },
security: { hsts: true, xframe: 'deny', noSniff: true }
},
app: { buildSha: process.env.GIT_SHA }
});
server.route({
method: 'GET',
path: '/health',
options: { auth: false, tags: ['ops'] },
handler: () => ({ status: 'ok', uptime: process.uptime() })
});
exports.init = async () => { await server.initialize(); return server; };
exports.start = async () => {
await server.start();
server.log(['boot'], `listening on ${server.info.uri}`);
return server;
};
process.on('unhandledRejection', (err) => { console.error(err); process.exit(1); });
Q3What is the h response toolkit, and what do h.response, h.continue and h.takeover do?
BasicResponse Toolkit
Answer
Every hapi lifecycle method receives (request, h). The h argument is the response toolkit, and it is the only sanctioned way to build a response object or signal what the lifecycle should do next. h.response(value) wraps a value in a response object you can chain on: .code(201), .header('Location', url), .type('application/json'), .etag(tag), .ttl(ms), .state('sid', value), .unstate('sid'), .redirect(path). If your handler simply returns a plain object, string, Buffer or stream, hapi wraps it for you with status 200, so the toolkit is only needed when you want to change something.
In extension points the semantics differ. Returning h.continue means 'I did not produce a response, carry on with the next lifecycle stage'. Returning a value from an extension replaces the response and, importantly, ends the lifecycle only if you call .takeover() on it, otherwise hapi treats a returned response from most extension points as a takeover already and skips ahead.
The safe rule interviewers want to hear: in server.ext() and in route prerequisites, return h.continue unless you intend to short-circuit, and when you do short-circuit, be explicit with .takeover(). Two more toolkit members matter in production: h.abandon tells hapi you have written to request.raw.res yourself and it should stop managing the response, and h.close does the same but ends the response for you. Both are escape hatches for server-sent events and hand-rolled streaming.
server.route({
method: 'POST',
path: '/orders',
handler: async (request, h) => {
const order = await createOrder(request.payload);
return h.response(order)
.code(201)
.header('Location', `/orders/${order.id}`)
.ttl(0);
}
});
// Extensions: h.continue means 'not my problem, keep going'
server.ext('onPreHandler', (request, h) => {
request.app.startedAt = Date.now();
return h.continue;
});
// takeover() ends the lifecycle right here
server.ext('onPreAuth', (request, h) => {
if (request.path.startsWith('/legacy')) {
return h.redirect('/v2' + request.path).takeover();
}
return h.continue;
});
// SSE: hapi stops managing the response after h.abandon
server.route({
method: 'GET',
path: '/events',
handler: (request, h) => {
const res = request.raw.res;
res.writeHead(200, { 'content-type': 'text/event-stream' });
const timer = setInterval(() => res.write('data: tick\n\n'), 1000);
request.events.once('disconnect', () => clearInterval(timer));
return h.abandon;
}
});
Key Points
- h.response() builds a chainable response object; plain returns are auto-wrapped
- h.continue means the extension produced nothing and the lifecycle proceeds
- takeover() explicitly ends the lifecycle from an extension or a pre method
- h.abandon and h.close hand raw socket control back to you for SSE and streaming
Q4How does hapi's router match paths, and what do {id}, {id?} and {path*} mean?
BasicRouting
Answer
hapi's router (the @hapi/call module) is deterministic. At registration time it decomposes each path into segments and sorts them by specificity: literal segments beat named parameters, named parameters beat partial-segment matches, and multi-segment wildcards sort last. That means /users/me and /users/{id} can coexist and the literal always wins regardless of registration order, which removes an entire class of Express bugs where a catch-all registered too early swallows later routes.
Parameter forms are: {id} for exactly one segment, {id?} for an optional segment that must be the last one in the path, {path*} for a greedy multi-segment capture that must also be last, and {path*3} for exactly three segments. You can also match part of a segment, so /geo/{lat}-{lng} parses '/geo/28.61-77.20' into two parameters. Method can be a string, an array of strings, or '*' for any verb.
Registering two routes whose paths and methods collide throws at boot with a message like 'New route /users/{id} conflicts with existing /users/{id}', which is a feature: you find the mistake in CI rather than in production. Two practical gotchas. Path parameters are URI-decoded by hapi, so a slug containing %2F will not silently split into segments. And hapi is strict about trailing slashes by default; set router: { stripTrailingSlash: true } on the server if your clients are inconsistent, otherwise /jobs/ and /jobs are different routes and one of them 404s.
// Registration order is irrelevant; hapi sorts by specificity
server.route([
{ method: 'GET', path: '/users/{id}', handler: getUser },
{ method: 'GET', path: '/users/me', handler: getSelf }, // literal wins
{ method: 'GET', path: '/files/{path*}', handler: serveTree }, // greedy, must be last
{ method: 'GET', path: '/reports/{year}/{month?}', handler: report },
{ method: 'GET', path: '/geo/{lat}-{lng}', handler: geo }, // partial segment
{ method: ['PUT', 'PATCH'], path: '/users/{id}', handler: updateUser }
]);
// Catch-all 404 with a consistent body
server.route({
method: '*',
path: '/{any*}',
options: { auth: false },
handler: (request, h) => h.response({ error: 'Not Found', path: request.path }).code(404)
});
// Boot-time safety net, not a runtime surprise:
// Error: New route /users/{id} conflicts with existing /users/{id}
const server = Hapi.server({ router: { stripTrailingSlash: true, isCaseSensitive: true } });
Q5What is @hapi/boom and how do you return correct HTTP errors from a hapi service?
BasicError Handling
Answer
Boom is hapi's error object. Any error thrown from a handler, extension, pre method or auth scheme is checked for the isBoom flag. If it is a Boom error, hapi serialises err.output.payload as the response body and uses err.output.statusCode.
If it is not, hapi wraps it in a 500 and, critically, discards the original message so internal details never reach the client: the body becomes { statusCode: 500, error: 'Internal Server Error', message: 'An internal server error occurred' }. That behaviour surprises people the first time a helpful error message vanishes in production. Boom exposes a factory per status code: Boom.badRequest, Boom.unauthorized, Boom.paymentRequired, Boom.forbidden, Boom.notFound, Boom.conflict, Boom.tooManyRequests, Boom.badImplementation, Boom.badGateway and so on.
Boom.badImplementation is specifically the 'my fault, hide it' constructor: the message you pass is retained on err.message for logging but replaced in the payload. To convert a third-party error while keeping its stack, use Boom.boomify(err, { statusCode, message, override }). For machine-readable API contracts, attach a code to err.output.payload or pass a data argument (Boom.badRequest(msg, data)) and render it in an onPreResponse extension. One rule interviewers listen for: never construct error responses by returning h.response({ error }).code(400) scattered across handlers, because then only some of your errors flow through the single formatting extension and your API contract drifts.
const Boom = require('@hapi/boom');
const handler = async (request) => {
const user = await db.users.byId(request.params.id);
if (!user) throw Boom.notFound('User not found');
if (user.tenantId !== request.auth.credentials.tenantId) {
throw Boom.forbidden('Cross-tenant access denied');
}
return user;
};
// Wrap an upstream failure without losing the original stack
try {
await paymentGateway.createOrder(payload);
} catch (err) {
throw Boom.boomify(err, { statusCode: 502, message: 'Gateway create failed', override: false });
}
// Machine-readable code for the client, human detail only in logs
const err = Boom.badRequest('Invalid IFSC code');
err.output.payload.code = 'IFSC_INVALID';
err.data = { field: 'bank.ifsc' };
throw err;
// 500 that logs the detail but never leaks it
throw Boom.badImplementation('ledger row missing for txn ' + id);
// client sees: { statusCode: 500, error: 'Internal Server Error',
// message: 'An internal server error occurred' }
Key Points
- Non-Boom errors become a 500 with the message stripped
- err.output.statusCode and err.output.payload define the wire format
- Boom.boomify preserves the stack when wrapping third-party errors
- Boom.badImplementation keeps detail for logs and hides it from the client
Q6How do you validate params, query and payload with Joi inside route options?
BasicValidation
Answer
hapi has first-class Joi integration through options.validate, which accepts params, query, payload, headers and state schemas plus an options object passed straight to Joi. Validation runs as a fixed lifecycle stage after authentication and authorisation and before onPreHandler, in the order headers, params, query, payload, state. Because it runs after auth, a request that fails authentication never reaches your schema, which matters when you are trying to keep unauthenticated CPU cost low.
Since Joi v17 the package is plain 'joi', not '@hapi/joi'; codebases still importing @hapi/joi are on an abandoned line and that is a common thing to spot in a code review round. Two Joi options carry real production weight. abortEarly defaults to true, so the client sees only the first failing field, which makes form UX poor; set it to false at the server level. stripUnknown removes keys not in the schema, while allowUnknown merely tolerates them, so stripUnknown is what actually protects you from mass assignment. Joi also coerces and defaults: a query parameter declared as Joi.number().integer().default(1) arrives at your handler as a real number, not a string, and hapi replaces request.query with the validated value. That mutation is the point, but it also means a schema that omits a key will delete it from request.query even though the caller sent it, which is the single most common 'my parameter disappeared' bug in hapi.
const Joi = require('joi');
server.route({
method: 'POST',
path: '/jobs/{jobId}/applications',
options: {
validate: {
params: Joi.object({ jobId: Joi.string().uuid().required() }),
query: Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20)
}),
payload: Joi.object({
email: Joi.string().email({ tlds: false }).required(),
phone: Joi.string().pattern(/^[6-9]\d{9}$/).required(),
expectedCtc: Joi.number().positive().max(100000000),
noticePeriodDays: Joi.number().integer().min(0).max(180).default(30)
}).required(),
headers: Joi.object({ 'x-request-id': Joi.string().guid() }).unknown(true),
options: { abortEarly: false, stripUnknown: true, convert: true }
}
},
handler: (request) => apply(request.params.jobId, request.payload)
});
Q7What is a hapi plugin, and what do register, dependencies and server.expose do?
BasicPlugins
Answer
A plugin is an object with a name, a version and an async register(server, options) function, usually exported as exports.plugin. Inside register you get a server object scoped to the plugin's own realm: routes, extensions, decorations, auth strategies and server methods you add there belong to the plugin. Register plugins with await server.register(plugin) or an array of { plugin, options, routes: { prefix }, once } entries.
Plugins are how hapi does composition instead of imports: every feature area of an application is a plugin, third-party integrations are plugins, and even the official static-file and templating support ship as the separate @hapi/inert and @hapi/vision plugins rather than living in core. Three fields decide most interview follow-ups. dependencies lists other plugin names that must be registered before this one becomes usable; hapi verifies it at initialisation and throws if the dependency is missing, so you cannot boot a half-wired server. once: true makes a repeated registration a no-op instead of an error, and multiple: true explicitly allows registering the same plugin more than once. server.expose(key, value) publishes something from the plugin realm to the outside world, reachable as server.plugins['plugin-name'].key from anywhere. The routes.prefix option applied at registration prepends a path prefix to every route the plugin registers, which is how you version an API without hard-coding /api/v1 into a hundred route definitions.
// plugins/metrics.js
exports.plugin = {
name: 'app-metrics',
version: '1.0.0',
dependencies: ['app-logger'],
once: true,
register: async (server, options) => {
const counters = new Map();
server.events.on('response', (request) => {
const key = `${request.method.toUpperCase()} ${request.route.path}`;
counters.set(key, (counters.get(key) || 0) + 1);
});
server.expose('snapshot', () => Object.fromEntries(counters));
server.route({
method: 'GET',
path: '/metrics',
options: { auth: false },
handler: () => Object.fromEntries(counters)
});
}
};
// server.js
await server.register([
require('./plugins/logger'),
{ plugin: require('./plugins/metrics'), options: { flushMs: 10000 } },
{ plugin: require('./api/candidates'), routes: { prefix: '/api/v1' } }
]);
// Anywhere else: server.plugins['app-metrics'].snapshot()
Key Points
- exports.plugin = { name, version, register: async (server, options) => {} }
- dependencies is verified at initialisation and fails the boot if unmet
- server.expose publishes into server.plugins['name']
- routes: { prefix: '/api/v1' } versions an API without touching route paths
Q8How do authentication strategies work in hapi, and what is the difference between mode required, optional and try?
BasicAuthentication
Answer
Authentication in hapi has three layers. A scheme is a factory function registered with server.auth.scheme(name, fn) that knows how to authenticate (basic, jwt, cookie, bearer). A strategy is a configured instance of a scheme created with server.auth.strategy(name, schemeName, options), and it is what routes actually reference. server.auth.default(strategy) applies a strategy to every route registered afterwards, and individual routes opt out with auth: false or override with auth: { strategy, mode, scope }.
Plugins like @hapi/basic, @hapi/cookie, @hapi/jwt and @hapi/bell each register a scheme; you still create the strategy yourself. The three modes are where candidates trip up. mode: 'required' is the default: credentials must be present and valid or the request fails with 401. mode: 'optional' means the credentials may be absent, but if they are present they must be valid, so a garbage token still returns 401. mode: 'try' means hapi attempts authentication and hands the request to your handler either way, with request.auth.isAuthenticated telling you what happened and request.auth.error holding the failure. 'try' is what you want for a public feed that personalises when a user is logged in. After a successful authentication you read request.auth.credentials (your object), request.auth.artifacts (scheme-specific data such as the decoded token), and request.auth.strategy. A classic production mistake is calling server.auth.default() after registering routes: the default only applies to routes registered later, so half your API silently becomes public.
const Basic = require('@hapi/basic');
const Bcrypt = require('bcryptjs');
await server.register(Basic);
server.auth.strategy('simple', 'basic', {
validate: async (request, username, password) => {
const user = await db.users.byEmail(username);
if (!user) return { isValid: false, credentials: null };
const ok = await Bcrypt.compare(password, user.hash);
return { isValid: ok, credentials: { id: user.id, scope: user.roles } };
}
});
// Must run BEFORE the routes it should protect
server.auth.default({ strategy: 'simple', mode: 'required' });
server.route({ method: 'GET', path: '/me', handler: (r) => r.auth.credentials });
server.route({ method: 'GET', path: '/health', options: { auth: false }, handler: () => 'ok' });
server.route({
method: 'GET',
path: '/feed',
options: { auth: { strategy: 'simple', mode: 'try' } },
handler: (r) => (r.auth.isAuthenticated ? personalFeed(r.auth.credentials) : publicFeed())
});
Q9What is server.inject() and why is it the standard way to test hapi routes?
BasicTesting
Answer
server.inject() runs a full request through the entire lifecycle without opening a TCP socket. It is powered by @hapi/shot, which fabricates a request and response object pair and hands them to hapi's internal dispatcher, so extensions, auth strategies, validation, pre methods, the handler, response validation and onPreResponse all execute exactly as they would in production. You get back a response object with statusCode, headers, payload (the raw string), result (the pre-serialisation value, which is far nicer to assert on than JSON.parse of the payload) and request (the full request object, useful for asserting on request.app values your extensions set).
Because there is no socket, tests are fast enough to run hundreds per second and there is no port allocation race in CI. Pair it with server.initialize() rather than server.start() so nothing binds a port at all. The killer feature for testing protected routes is the auth option: pass { strategy, credentials, artifacts } and hapi injects those credentials directly, skipping the scheme.
That means you can test scope enforcement on an admin route without minting a real JWT or standing up an identity provider. Two cautions worth raising in an interview. First, inject bypasses anything that lives below hapi, so TLS termination, proxy headers and real socket timeouts are not covered and still need a smoke test against a running instance. Second, res.result is the object your handler returned, so a test that asserts on res.result will pass even if your serialisation layer is broken; assert on res.payload when the wire format itself is the contract.
const { init } = require('../server');
describe('applications API', () => {
let server;
beforeAll(async () => { server = await init(); }); // no port bound
afterAll(async () => { await server.stop(); });
it('404s for an unknown job', async () => {
const res = await server.inject({ method: 'GET', url: '/api/v1/jobs/nope' });
expect(res.statusCode).toBe(404);
expect(res.result.message).toBe('Job not found');
});
it('enforces scope without minting a real token', async () => {
const res = await server.inject({
method: 'GET',
url: '/api/v1/admin/payouts',
auth: { strategy: 'jwt', credentials: { userId: 'u1', scope: ['support'] } }
});
expect(res.statusCode).toBe(403);
});
it('rejects an oversized payload', async () => {
const res = await server.inject({
method: 'POST',
url: '/api/v1/uploads',
payload: Buffer.alloc(3 * 1024 * 1024)
});
expect(res.statusCode).toBe(413);
});
});
Key Points
- Runs the full lifecycle in-process via @hapi/shot; no socket, no port
- res.result is the pre-serialisation value; res.payload is the wire string
- The auth option injects credentials and skips the scheme entirely
- Does not cover TLS, proxies or real socket timeouts
Q10How do you serve static files and render templates in hapi?
BasicStatic and Views
Answer
Neither capability is in core. Static file serving comes from @hapi/inert, which decorates the toolkit with h.file() and adds the file and directory route handler shorthands. Templating comes from @hapi/vision, which adds server.views() and h.view().
Register them like any other plugin, and register them once at the root server so the decorations are available everywhere. With inert, a directory handler takes path (a string, array or function), listing, index, redirectToSlash, defaultExtension and lookupCompressed. The route path must end in a multi-segment parameter such as /assets/{param*} because the wildcard is what maps onto the file tree.
Set listing: false unless you genuinely want directory browsing, and never point path at a directory that contains .env, config or node_modules; inert will happily serve them. Path traversal itself is handled, inert rejects ../ escapes, but it cannot know that you pointed it at the wrong root. With vision, server.views() takes engines (a map of extension to a compile-capable module such as handlebars, pug or ejs), path, layout, partialsPath, helpersPath, relativeTo and isCached.
Leave isCached true in production and false in development, otherwise every template edit needs a restart. In practice most hapi services in 2026 are JSON APIs and use neither plugin, so the honest interview answer includes 'for a pure API I would not register inert at all, I would put static assets behind CloudFront or nginx'.
const Inert = require('@hapi/inert');
const Vision = require('@hapi/vision');
await server.register([Inert, Vision]);
server.views({
engines: { hbs: require('handlebars') },
relativeTo: __dirname,
path: 'templates',
layout: 'default',
partialsPath: 'templates/partials',
isCached: process.env.NODE_ENV === 'production'
});
server.route({
method: 'GET',
path: '/assets/{param*}',
options: { auth: false, cache: { expiresIn: 7 * 24 * 3600 * 1000, privacy: 'public' } },
handler: {
directory: { path: 'public', index: false, listing: false, redirectToSlash: false }
}
});
server.route({
method: 'GET',
path: '/jobs/{slug}',
options: { auth: false },
handler: async (request, h) => {
const job = await getJob(request.params.slug);
if (!job) throw Boom.notFound();
return h.view('job', { job });
}
});
Q11How do you configure CORS in hapi, and what breaks when you combine origin '*' with credentials?
BasicSecurity
Answer
CORS in hapi is route configuration, not middleware. Set routes.cors on the server for the default and override per route with options.cors. The object accepts origin (an array of allowed origins, or ['*'], or 'ignore'), credentials, additionalHeaders, additionalExposedHeaders, maxAge, exposedHeaders and preflightStatusCode. hapi handles the OPTIONS preflight automatically for any route that has cors enabled; you do not register an OPTIONS route yourself, and in fact doing so conflicts with the generated one.
The combination interviewers probe is origin: ['*'] with credentials: true. hapi will emit Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true, and every browser rejects that pair outright, so cookie-based sessions silently fail cross-origin while curl works fine. The fix is to enumerate real origins. When you list explicit origins, hapi echoes the matching request origin back and adds Vary: origin, which is important because a shared CDN or reverse proxy will otherwise cache the first origin's response and serve it to a second origin.
The other common failure is forgetting additionalHeaders. hapi allows a default set of request headers, so a custom header like x-request-id or x-tenant causes the preflight to fail with no obvious server-side error; the browser console is the only place you see it. Finally, remember that a request to a path with no matching route is answered by the catch-all or by hapi's internal 404, which uses server-level CORS defaults, so a 404 can look like a CORS error to a frontend team.
const server = Hapi.server({
port: 3000,
routes: {
cors: {
origin: ['https://goodspace.ai', 'https://admin.goodspace.ai'],
credentials: true, // never pair this with ['*']
additionalHeaders: ['x-request-id', 'x-tenant'],
additionalExposedHeaders: ['x-total-count', 'x-ratelimit-remaining'],
maxAge: 86400
}
}
});
// A genuinely public, cookie-free endpoint can widen it
server.route({
method: 'GET',
path: '/public/jobs',
options: { auth: false, cors: { origin: ['*'], credentials: false } },
handler: listPublicJobs
});
// Turn it off entirely for an internal service-to-service route
server.route({
method: 'POST',
path: '/internal/reindex',
options: { cors: false },
handler: reindex
});
Q12What is the default payload maxBytes in hapi and how do you handle larger uploads?
BasicPayload
Answer
The default is 1048576 bytes, exactly 1 MB, per route. Exceed it and hapi returns 413 with the body { statusCode: 413, error: 'Request Entity Too Large', message: 'Payload content length greater than maximum allowed: 1048576' }. That number surprises teams migrating from Express, where body-parser's default is 100 KB but people usually configure it explicitly.
Payload handling lives in options.payload and is processed by @hapi/subtext. The keys that matter: maxBytes, output ('data' for a parsed object or Buffer, 'stream' for a readable stream, 'file' to write to a temp file on disk), parse (true, false, or 'gunzip'), allow (restrict content types), multipart (opt-in since hapi v19, so a multipart form silently fails to parse if you forget it), timeout (default 10000 ms for receiving the body), and defaultContentType. Raising maxBytes globally is the wrong instinct.
A 50 MB limit on every route means an attacker can hold 50 MB of heap per concurrent request, and Node will OOM long before your CPU saturates. Set a tight server-wide default (1 to 2 MB) and raise it only on the specific upload routes, and on those routes use output: 'stream' or 'file' so the bytes never accumulate in a Buffer. With output: 'file' hapi writes to os.tmpdir() and you are responsible for unlinking the file after the handler finishes, otherwise a busy pod fills its ephemeral disk in a day.
// Tight global default
const server = Hapi.server({ routes: { payload: { maxBytes: 1048576, timeout: 10000 } } });
// Raised only where it is needed, and streamed rather than buffered
server.route({
method: 'POST',
path: '/candidates/{id}/resume',
options: {
payload: {
maxBytes: 10 * 1024 * 1024,
output: 'stream',
parse: true,
multipart: { output: 'stream' }, // opt-in since hapi v19
allow: 'multipart/form-data',
timeout: 30000
}
},
handler: async (request) => {
const file = request.payload.resume; // a readable stream
const type = file.hapi.headers['content-type'];
if (type !== 'application/pdf') throw Boom.unsupportedMediaType('PDF only');
await uploadStreamToS3(file, `resumes/${request.params.id}.pdf`);
return { uploaded: true };
}
});
// 413 body when the limit is hit:
// { statusCode: 413, error: 'Request Entity Too Large',
// message: 'Payload content length greater than maximum allowed: 1048576' }
Key Points
- Default maxBytes is 1048576 (1 MB) per route; exceeding it returns 413
- multipart parsing is opt-in from hapi v19 onward
- output: 'stream' or 'file' keeps large bodies out of the heap
- With output: 'file' you must unlink the temp file yourself
Q13How do you set, read and clear cookies with server.state in hapi?
BasicCookies
Answer
server.state(name, options) declares a cookie definition once, then h.response().state(name, value) sets it and h.response().unstate(name) clears it. Incoming cookies are parsed into request.state during the cookies-processing stage of the lifecycle, which runs before authentication, so a cookie-based auth scheme can read them. Declaring the cookie up front rather than writing Set-Cookie by hand is what makes hapi's cookie story unusually safe: the definition carries ttl, isSecure, isHttpOnly, isSameSite, path, domain, encoding, and clearInvalid, and hapi applies them consistently to every write.
The encoding option is the interesting one. 'none' stores the raw string, 'base64json' serialises an object, and 'iron' encrypts and signs the value with @hapi/iron using a password of at least 32 characters. Iron gives you a tamper-proof cookie that carries real data, which is how @hapi/cookie implements stateless sessions without a session store. isSameSite defaults to 'Strict' in hapi, which is stricter than most frameworks and breaks OAuth redirect flows and cross-site embeds; 'Lax' is the usual correction for a login redirect. Two gotchas. strictHeader defaults to true and rejects cookies whose value violates RFC 6265, so a third-party analytics cookie with an unusual character can fail the whole request with 400 Bad Request Cookie; setting clearInvalid: true or ignoreErrors: true on that definition is the pragmatic fix. And isSecure: true means the cookie is never sent over plain HTTP, so local development against http://localhost needs the flag driven by NODE_ENV.
server.state('sid', {
ttl: 24 * 60 * 60 * 1000,
isSecure: process.env.NODE_ENV === 'production',
isHttpOnly: true,
isSameSite: 'Lax', // hapi defaults to 'Strict'
path: '/',
encoding: 'iron', // encrypted + signed via @hapi/iron
password: process.env.COOKIE_SECRET, // 32 chars minimum
clearInvalid: true,
strictHeader: true
});
server.route({
method: 'POST',
path: '/login',
options: { auth: false },
handler: async (request, h) => {
const session = await createSession(request.payload);
return h.response({ ok: true }).state('sid', { sid: session.id, uid: session.userId });
}
});
server.route({
method: 'POST',
path: '/logout',
handler: (request, h) => h.response({ ok: true }).unstate('sid')
});
// Reading it later: request.state.sid.uid (already decrypted by hapi)
Q14What is the difference between server.initialize() and server.start()?
BasicServer Lifecycle
Answer
server.initialize() performs every startup step except binding the listener. It validates the route table, verifies plugin dependencies, provisions catbox cache connections, and runs all onPreStart extensions. server.start() calls initialize() internally if it has not run, then binds the TCP listener and runs onPostStart extensions. Both are async and both throw if any step fails, so a missing plugin dependency or an unreachable Redis cache surfaces as a rejected promise at boot rather than as a 500 an hour later.
The split exists mainly for testing. Calling initialize() in a test suite means server.inject() exercises the complete lifecycle, including caches and startup hooks, without allocating a port, which removes flaky EADDRINUSE failures when CI runs suites in parallel. It is also the right hook for a Kubernetes readiness model: initialize during container start, run your migrations or warm your caches in onPreStart, and only call start() once the process is genuinely able to serve traffic.
The four server extension points pair with these calls and are frequently asked as a set: onPreStart (before the listener binds, ideal for database connections and cache warming), onPostStart (after the listener is up, for registering with service discovery or emitting a ready metric), onPreStop (before hapi stops accepting connections, for draining queues or deregistering), and onPostStop (after the listener is closed and in-flight requests have drained, for closing database pools). Unlike request extensions these receive the server object rather than (request, h), and an error thrown from onPreStart aborts the boot.
// Tests: no port bound, full lifecycle still runs
const server = Hapi.server({ port: 0 });
await server.initialize();
const res = await server.inject('/health');
// Production
await server.start();
console.log(server.info.uri, server.info.port, server.info.started);
// Server extension points receive the server, not (request, h)
server.ext('onPreStart', async (srv) => {
await db.connect();
await srv.methods.jobs.warmTopSlugs();
});
server.ext('onPostStart', async (srv) => {
srv.log(['boot'], { pid: process.pid, uri: srv.info.uri });
});
server.ext('onPreStop', async (srv) => {
await deregisterFromLoadBalancer();
});
server.ext('onPostStop', async (srv) => {
await db.destroy();
await redis.quit();
});
Key Points
- initialize() does everything except bind the socket
- start() implies initialize() and then binds plus runs onPostStart
- Use initialize() in tests so inject() works with no port allocation
- onPreStart, onPostStart, onPreStop, onPostStop take the server argument
Q15Walk through the hapi request lifecycle and name the extension points you can hook.
IntermediateLifecycle
Answer
This is the single most predictive hapi question, because everything else in the framework is positioned relative to this sequence. The order is: onRequest, route lookup, JSONP processing, cookie processing, onPreAuth, authentication, payload processing, payload authentication, onCredentials, authorization (scope and entity checks), onPostAuth, path parameter validation, query validation, payload validation, state validation, onPreHandler, route prerequisites, the handler, onPostHandler, response validation, onPreResponse, response transmission, then request finalisation. Five of those are yours to hook with server.ext(): onRequest, onPreAuth, onCredentials, onPostAuth, onPreHandler, onPostHandler and onPreResponse.
Each detail has a consequence. onRequest fires before route lookup, so request.route is not yet resolved and you cannot read route options there, but it is the only place you can call request.setUrl() or request.setMethod() to rewrite an incoming request, which is how legacy URL rewriting is done in hapi. Validation runs after authentication, so an unauthenticated caller never pays your Joi cost, which is a genuine DoS consideration. onPostHandler sees request.response as a real response object and can mutate or replace it, but it does not run when the handler threw. onPreResponse is the only extension that runs for every outcome including errors, 404s and validation failures, which is why every production hapi service ends up with exactly one onPreResponse extension doing error shaping and header stamping. Extensions can also be scoped: server.ext accepts an options object with sandbox: 'plugin' to limit the extension to routes in the current plugin realm, and route: { prefix } filters, plus a before and after ordering hint when several plugins register at the same point.
// Ordered request lifecycle; CAPS are the server.ext() hooks
// 1 ONREQUEST route not matched yet; setUrl / setMethod allowed
// 2 route lookup 404 raised here
// 3 cookie processing request.state populated
// 4 ONPREAUTH
// 5 authentication scheme.authenticate -> h.authenticated({ credentials })
// 6 payload processing then payload authentication
// 7 ONCREDENTIALS last chance to mutate credentials before scope checks
// 8 authorization scope / entity / access rules
// 9 ONPOSTAUTH
// 10 validation headers -> params -> query -> payload -> state
// 11 ONPREHANDLER
// 12 route prerequisites (options.pre) then the handler
// 13 ONPOSTHANDLER response exists; skipped when the handler threw
// 14 response validation (options.response)
// 15 ONPRERESPONSE runs for successes AND errors
// 16 transmission, then finalisation
server.ext('onRequest', (request, h) => {
if (request.path.startsWith('/v1/')) {
request.setUrl(request.path.replace('/v1/', '/api/v1/') + (request.url.search || ''));
}
request.app.receivedAt = process.hrtime.bigint();
return h.continue;
});
server.ext('onPostAuth', (request, h) => {
request.app.tenantId = request.auth.credentials?.tenantId;
return h.continue;
}, { sandbox: 'plugin' });
Key Points
- onRequest is pre-routing: no request.route, but setUrl and setMethod work
- Authentication and authorization run before any Joi validation
- onPostHandler is skipped when the handler throws; onPreResponse is not
- server.ext accepts sandbox, before and after for ordering across plugins
Q16How do you build one consistent error contract for a hapi API using onPreResponse?
IntermediateError Handling
Answer
onPreResponse is the only lifecycle point that sees every outcome: successful responses, thrown Boom errors, non-Boom errors already wrapped into 500s, validation failures, 404s from unmatched paths and 401s from auth schemes. That makes it the correct and only place to enforce a single JSON error envelope. The pattern is to read request.response, branch on isBoom, and return a fresh response for the error case.
Details that separate a working implementation from a good one: check response.output.statusCode rather than any custom field, since hapi builds output for you. Log 5xx with the stack and swallow the message for the client, but pass 4xx messages through, because those are the client's own fault and hiding them makes integration painful for whoever is consuming your API. Stamp request.info.id (hapi generates one per request) into both the log line and the response body so a support ticket carries a searchable identifier.
Preserve any code you attached in Boom, since that is the machine-readable contract your mobile app switches on. And return the response, do not mutate response.output.payload in place, because mutation misses the content-length recalculation in some cases and is harder to reason about. Two traps.
First, an error thrown from inside onPreResponse itself will not re-enter the extension, so guard your formatting code defensively or you will produce an unformatted 500. Second, onPreResponse also runs for static file and stream responses, so check that response.isBoom is false before assuming you can read a body.
server.ext('onPreResponse', (request, h) => {
const response = request.response;
if (!response.isBoom) {
response.header('x-request-id', request.info.id);
return h.continue;
}
const status = response.output.statusCode;
const payload = response.output.payload;
if (status >= 500) {
request.log(['error', 'unhandled'], {
msg: response.message,
stack: response.stack,
route: request.route.path,
method: request.method
});
}
return h
.response({
error: {
code: payload.code || payload.error.toUpperCase().replace(/ /g, '_'),
message: status >= 500 ? 'Something went wrong on our side' : response.message,
details: response.data?.details || payload.details,
requestId: request.info.id
}
})
.code(status)
.header('x-request-id', request.info.id);
});
Q17What are route prerequisites (options.pre) and when are they better than calling services from the handler?
IntermediatePrerequisites
Answer
options.pre is an array of steps that run after onPreHandler and before the handler. Each entry is either a function, or an object { method, assign, failAction }, and results land on request.pre[assign] (with the raw response objects on request.preResponses). The structural feature that makes pre worth using is nesting: an inner array runs its members in parallel, and the outer array runs sequentially.
So [[loadJob, loadCandidate], assertNoDuplicate] fires two independent database reads concurrently and only then runs the check that depends on both. Writing that by hand in a handler means Promise.all plus destructuring, which is fine, but you lose the declarative benefit: with pre, the route object tells a reviewer exactly what data the handler needs. Prerequisites also compose with takeover.
A pre method can throw a Boom error to abort the request with the right status, or return h.response(...).takeover() to short-circuit with a full response, which is how you implement 'if a cached ETag matches, return 304 and never touch the handler'. hapi even ships h.entity({ etag, modified }) for exactly that. failAction on a pre entry lets a non-critical enrichment step fail without killing the request. Where pre goes wrong is over-use: teams turn every service call into a prerequisite and end up with routes whose real logic is spread across six named functions in different files. The rule I would give in an interview is to use pre for loading and authorising the resources a route operates on, and keep the actual business transition in the handler or a service.
server.route({
method: 'POST',
path: '/jobs/{jobId}/applications',
options: {
pre: [
[
{ method: loadJob, assign: 'job' }, // these two run
{ method: loadCandidate, assign: 'candidate' } // in parallel
],
{ method: assertNoDuplicate, assign: 'dedupe' }, // then this
{ method: enrichFromResumeParser, assign: 'parsed', failAction: 'log' }
]
},
handler: (request) =>
createApplication(request.pre.job, request.pre.candidate, request.pre.parsed)
});
async function loadJob(request, h) {
const job = await db.jobs.byId(request.params.jobId);
if (!job) throw Boom.notFound('Job not found');
if (job.status !== 'ACTIVE') {
return h.response({ error: 'JOB_CLOSED' }).code(409).takeover();
}
return job;
}
async function loadCandidate(request) {
return db.candidates.byId(request.auth.credentials.userId);
}
Key Points
- Nested arrays run in parallel; the outer array runs in sequence
- Results are exposed on request.pre[assign]
- A pre method can throw Boom or return .takeover() to skip the handler
- failAction: 'log' makes an optional enrichment step non-fatal
Q18How does failAction work, and why do validation errors look generic in production?
IntermediateValidation
Answer
failAction controls what hapi does when validation fails, and it can be set on options.validate.failAction, options.response.failAction, options.payload.failAction and options.state.failAction, plus at the server level under routes. The accepted values are 'error' (the default, respond with an error), 'log' (emit a request event tagged with the failure and continue with the unvalidated value), 'ignore' (silently continue), or a function (request, h, err) where you decide. The behaviour that catches people out is the default error path. hapi checks process.env.NODE_ENV, and when it is 'production' it replaces the detailed Joi message with a generic one such as 'Invalid request payload input' so that your schema shape, field names and constraints do not leak to an attacker probing the API.
Developers see rich messages locally, then deploy and get complaints from the frontend team that the API stopped explaining itself. There is no flag to turn that off, you supply your own failAction function. When you do, decide deliberately how much to reveal.
My preference is to return field paths and a stable machine code but not the Joi constraint text, and to log the full err.details server-side with the request id. Two related points interviewers probe. First, err.details only exists when abortEarly is false or when Joi produced a ValidationError; guard with a fallback. Second, failAction on options.response is where you want 'log' in production, because failing a customer's request because your own outgoing shape drifted is worse than shipping the slightly wrong body and alerting on the log.
const Boom = require('@hapi/boom');
const validationFailAction = (request, h, err) => {
request.log(['validation', 'error'], { path: request.path, detail: err.message });
const boom = Boom.badRequest('Request validation failed');
boom.output.payload.code = 'VALIDATION_FAILED';
boom.output.payload.details = (err.details || []).map((d) => ({
field: d.path.join('.'),
rule: d.type
}));
throw boom;
};
const server = Hapi.server({
routes: {
validate: {
options: { abortEarly: false, stripUnknown: true },
failAction: validationFailAction
},
response: {
// never 500 a customer because our own output drifted
failAction: 'log',
sample: 5
}
}
});
server.events.on({ name: 'request', channels: 'internal' }, (request, event) => {
if (event.tags.includes('response') && event.tags.includes('error')) {
metrics.increment('response_schema_drift', { route: request.route.path });
}
});
Q19How do server methods and catbox caching work, and what do staleIn, staleTimeout and generateTimeout do?
IntermediateCaching
Answer
server.method(name, fn, options) registers a function on server.methods, and its main reason to exist is the built-in catbox caching layer. Give it options.cache and hapi memoises the result keyed by the arguments, with the key derived automatically for simple string, number and boolean arguments or by your own generateKey function for object arguments. Forgetting generateKey when your method takes an object is the classic bug: hapi cannot key on it, and the method silently runs uncached.
The cache options are where the depth is. expiresIn is the total TTL in milliseconds (or use expiresAt for a daily wall-clock expiry). generateTimeout is mandatory and is how long catbox waits for your function before giving up and returning a 503-class error; without it a slow upstream backs up unbounded. staleIn marks the value stale before it expires, and staleTimeout is how long a request will wait for a fresh regeneration before being served the stale copy while the refresh continues in the background. That pair is stale-while-revalidate, and it is the reason to use server methods at all rather than a hand-rolled Redis get/set: your p99 stops tracking your upstream's p99. The constraint is that staleIn must be less than expiresIn and staleTimeout must be less than the difference.
Provision the backing store on the server with the cache option, pointing at @hapi/catbox-redis for anything running more than one pod, because the default @hapi/catbox-memory is per-process, invisible to your other replicas, and counts against the same heap your requests use. Every cached method exposes stats at server.methods.x.cache.stats, which is worth exporting to your metrics backend.
const CatboxRedis = require('@hapi/catbox-redis');
const server = Hapi.server({
port: 3000,
cache: [{
name: 'redis',
provider: {
constructor: CatboxRedis.Engine,
options: { host: process.env.REDIS_HOST, port: 6379, partition: 'gs-api' }
}
}]
});
server.method('jobs.bySlug', async (slug) => db.jobs.bySlug(slug), {
cache: {
cache: 'redis',
segment: 'jobs',
expiresIn: 5 * 60 * 1000,
staleIn: 4 * 60 * 1000, // < expiresIn
staleTimeout: 100, // serve stale after 100ms, refresh behind
generateTimeout: 3000 // required whenever cache is set
},
generateKey: (slug) => `slug:${slug}`
});
// Object arguments need an explicit key or the method runs uncached
server.method('search.run', runSearch, {
cache: { cache: 'redis', segment: 'search', expiresIn: 60000, generateTimeout: 5000 },
generateKey: (q) => `${q.term}|${q.city}|${q.page}`
});
// const job = await server.methods.jobs.bySlug('node-developer-noida');
// server.methods.jobs.bySlug.cache.stats -> { hits, gets, generates, errors, stales }
Key Points
- generateTimeout is mandatory when caching and bounds a slow upstream
- staleIn plus staleTimeout gives stale-while-revalidate behaviour
- Object arguments require generateKey or the cache silently misses
- catbox-memory is per-process; use catbox-redis across replicas
Q20How do you implement JWT authentication with @hapi/jwt, including scopes and dynamic scopes?
IntermediateAuthentication
Answer
Register @hapi/jwt, then create a strategy with server.auth.strategy(name, 'jwt', { keys, verify, validate }). keys accepts a shared secret, a key object with an algorithm, or a JWKS endpoint descriptor for rotating keys from an identity provider. verify carries the claim checks hapi performs for you before your code runs: aud, iss, sub, nbf, exp and maxAgeSec. Set verify: false only if you are validating manually, because turning off aud and iss checks is how a token minted for a different service gets accepted by yours. validate(artifacts, request, h) receives the decoded token and must return { isValid, credentials, response }, and it is where you attach the credentials object that becomes request.auth.credentials. Put the scope array on credentials, because hapi's authorisation stage reads credentials.scope.
Route scope rules are richer than most frameworks: scope: ['admin', 'support'] means any one of them, '+finance' means required in addition, '!banned' means forbidden, and a dynamic scope written as '{params.tenantId}' or '{query.orgId}' is interpolated from the request at match time, which lets one route enforce that the caller's scope contains the very tenant they are asking about. There is also access: [{ scope, entity }] for combining rules, and entity: 'user' or 'app' to distinguish user tokens from machine tokens. On the operational side: keep maxAgeSec short, do not put anything you cannot afford to leak in the payload since a JWT is signed rather than encrypted, and remember that a stateless token cannot be revoked, so a logout or a ban needs a denylist checked in validate against Redis.
const Jwt = require('@hapi/jwt');
await server.register(Jwt);
server.auth.strategy('jwt', 'jwt', {
keys: { uri: 'https://auth.example.in/.well-known/jwks.json' },
verify: {
aud: 'goodspace-api',
iss: 'https://auth.example.in/',
sub: false,
nbf: true,
exp: true,
maxAgeSec: 3600,
timeSkewSec: 15
},
validate: async (artifacts, request) => {
const claims = artifacts.decoded.payload;
if (await redis.sismember('jwt:revoked', claims.jti)) {
return { isValid: false };
}
return {
isValid: true,
credentials: {
userId: claims.sub,
tenantId: claims.tenant,
scope: claims.scope ? claims.scope.split(' ') : []
}
};
}
});
server.auth.default('jwt');
server.route({
method: 'GET',
path: '/tenants/{tenantId}/payouts',
options: { auth: { strategy: 'jwt', scope: ['admin', '+finance', '{params.tenantId}'] } },
handler: (request) => listPayouts(request.params.tenantId)
});
Q21What does server.decorate do, and how do the server, request, toolkit and handler targets differ?
IntermediateExtensibility
Answer
server.decorate(type, property, method, options) adds a property to one of four hapi objects, and choosing the right target is the point of the question. 'server' attaches to the server object and therefore to request.server and to every plugin's scoped server, which is how you share a database client or a feature-flag client without a global import. 'request' attaches to every request object; by default the value is whatever you pass, but with { apply: true } you pass a function that receives the request and hapi evaluates it once per request and caches the result, which is how you compute something derived like a tenant id or a correlation id lazily. 'toolkit' attaches to h, giving you response helpers such as h.ok(data) or h.paginated(rows, total); a toolkit decoration must be a regular function, not an arrow function, because hapi binds this to the toolkit and you need this.response inside it. 'handler' registers a named handler shorthand so a route can say handler: { proxyTo: { base } } instead of a function, which is exactly how @hapi/inert adds handler: { file } and handler: { directory }. Two operational notes. Decorating a name that already exists throws, so libraries should namespace, and passing { extend: true } lets you wrap an existing decoration instead of colliding with it.
And decorations are global by default: even when registered inside a plugin they attach to the root server, unless you pass options that sandbox them. In TypeScript codebases you also need declaration merging on the hapi module interfaces or every use of your decoration is a type error, which is a detail worth mentioning because it shows you have shipped hapi with types rather than just read the docs.
// server target: shared clients, reachable as request.server.db
server.decorate('server', 'db', knexInstance);
// request target with apply:true, computed lazily once per request
server.decorate('request', 'tenantId', (request) => {
return request.auth.credentials?.tenantId || request.headers['x-tenant'] || null;
}, { apply: true });
// toolkit target: MUST be a regular function so `this` is the toolkit
server.decorate('toolkit', 'paginated', function (rows, total, page) {
return this.response({ data: rows, meta: { total, page } })
.header('x-total-count', String(total));
});
// handler target: named route handler shorthand
server.decorate('handler', 'proxyTo', (route, options) => {
return async (request, h) => {
const { payload } = await Wreck.get(options.base + request.path, { json: true });
return h.response(payload);
};
});
server.route({
method: 'GET',
path: '/orders',
handler: async (request, h) => {
const { rows, total } = await listOrders(request.tenantId, request.query.page);
return h.paginated(rows, total, request.query.page);
}
});
server.route({ method: 'GET', path: '/legacy/{p*}', handler: { proxyTo: { base: 'http://legacy:8080' } } });
Key Points
- server, request, toolkit and handler are the four decoration targets
- { apply: true } on a request decoration evaluates per request and caches
- Toolkit decorations need a regular function so `this` binds to h
- Name collisions throw; use { extend: true } to wrap an existing decoration
Q22A payment provider signs its webhook over the raw body. How do you verify that in hapi?
IntermediatePayload
Answer
HMAC signatures are computed over the exact bytes the provider sent. If hapi parses the JSON and you re-serialise it, key order, whitespace and unicode escaping can all change, and the signature will not match, intermittently, which is the worst kind of bug. The hapi answer is options.payload.parse = false, which leaves request.payload as a Buffer of the raw body.
Set output: 'data' so you get a Buffer rather than a stream, restrict allow to the provider's content type, and set a tight maxBytes because a webhook endpoint is unauthenticated by definition and therefore a free target. Then compute the HMAC over the Buffer and compare with crypto.timingSafeEqual rather than ===, because a naive string comparison leaks timing information about how many leading characters matched. timingSafeEqual throws if the two buffers have different lengths, so check length first. After verification, JSON.parse the buffer yourself.
Three production details that separate a working webhook from a reliable one. Enqueue the event and return 204 immediately rather than doing the work in the handler; providers retry aggressively on timeout and you will process the same event several times. Deduplicate on the provider's event id, because at-least-once delivery is the contract every payment gateway offers, Razorpay and Stripe included.
And check the event timestamp against a tolerance window so a captured request cannot be replayed a week later. Also remember that with parse: false, options.validate.payload will not run against an object, so any schema you had on that route needs to move after the manual JSON.parse.
const Crypto = require('crypto');
server.route({
method: 'POST',
path: '/webhooks/payments',
options: {
auth: false,
payload: {
parse: false, // request.payload stays a raw Buffer
output: 'data',
allow: 'application/json',
maxBytes: 256 * 1024,
timeout: 5000
}
},
handler: (request, h) => {
const raw = request.payload;
const sent = Buffer.from(request.headers['x-webhook-signature'] || '', 'utf8');
const expected = Buffer.from(
Crypto.createHmac('sha256', process.env.WEBHOOK_SECRET).update(raw).digest('hex'),
'utf8'
);
if (sent.length !== expected.length || !Crypto.timingSafeEqual(sent, expected)) {
throw Boom.unauthorized('Bad signature');
}
const event = JSON.parse(raw.toString('utf8'));
if (Math.abs(Date.now() - event.created_at * 1000) > 5 * 60 * 1000) {
throw Boom.badRequest('Stale webhook');
}
queue.publish('payments.events', event); // ack fast, process async
return h.response().code(204);
}
});
Key Points
- payload.parse = false keeps request.payload as the raw Buffer
- Compare digests with crypto.timingSafeEqual, never with ===
- Ack with 204 and process asynchronously; gateways retry on timeout
- Deduplicate on the provider event id; delivery is at-least-once
Q23How do route options.cache and h.entity give you correct HTTP caching and 304 responses?
IntermediateHTTP Caching
Answer
options.cache on a route controls the outgoing cache-control header, and it is separate from catbox, which caches on the server side. The fields are expiresIn (milliseconds) or expiresAt (a wall-clock time like '23:00' for daily expiry), privacy ('default', 'public' or 'private'), statuses (which status codes get the header, 200 by default) and otherwise (the header value when the route is not cacheable, 'no-cache' by default). privacy matters more than people think: a response containing user-specific data served with cache-control: public will be stored by CDNs and shared proxies and handed to the next user, and this has caused real data-leak incidents. Anything behind auth should be privacy: 'private' at minimum and otherwise: 'no-store' when it contains KYC or payment data.
The second half is conditional requests. h.entity({ etag, modified }) checks the incoming if-none-match and if-modified-since headers against the values you supply. If they match, it returns a fully formed 304 response that you return immediately, and your handler never loads the body. That is the pattern worth showing in an interview: fetch a cheap version marker first, call h.entity, and only fetch the expensive representation when the client's copy is stale.
If you build the body anyway you can still call response.etag(tag) and hapi will handle the comparison, but you have already paid the cost. One subtlety: hapi appends a weak-etag suffix when the response is compressed, so a hand-rolled if-none-match comparison against your stored tag will not match; let hapi do the comparison, and pass { vary: false } to etag() only if you are certain no content negotiation applies.
server.route({
method: 'GET',
path: '/jobs/{slug}',
options: {
auth: false,
cache: { expiresIn: 60 * 1000, privacy: 'public' },
handler: async (request, h) => {
const meta = await server.methods.jobs.metaBySlug(request.params.slug);
if (!meta) throw Boom.notFound('Job not found');
// 304 without ever loading the full document
const cached = h.entity({ etag: meta.version, modified: meta.updatedAt });
if (cached) return cached;
const job = await server.methods.jobs.bySlug(request.params.slug);
return h.response(job).etag(meta.version).header('vary', 'accept-encoding');
}
}
});
// Per-user data must never reach a shared proxy
server.route({
method: 'GET',
path: '/me/applications',
options: { cache: { expiresIn: 30 * 1000, privacy: 'private' } },
handler: listMine
});
server.route({
method: 'GET',
path: '/me/kyc',
options: { cache: { privacy: 'private', otherwise: 'no-store' } },
handler: kycStatus
});
Q24How do you structure a large hapi codebase, and what does @hapi/glue add?
IntermediateArchitecture
Answer
The idiomatic structure is one plugin per bounded feature, registered with a route prefix, plus a small set of infrastructure plugins for the database, logging, auth strategies and caching. Each feature plugin owns its routes, its Joi schemas, its server methods and its own tests, and declares dependencies on the infrastructure plugins it needs so a misordered registration fails at boot. The realm boundary makes this work: an extension registered inside a plugin with sandbox: 'plugin' only applies to that plugin's routes, so a feature can add its own onPreResponse without affecting the rest of the API.
Anything a feature wants to share goes through server.expose, and anything global goes through server.decorate on the root server. @hapi/glue removes the imperative wiring. You describe the server options and the plugin list in a manifest, a plain object that can be built from environment-specific configuration, and Glue.compose(manifest) returns a fully registered server. The benefit is not the twenty lines of register() calls you save, it is that the manifest is data: you can diff it, generate it, and swap plugins per environment (a stub payment plugin in staging, the real one in production) without touching code.
Pair it with @hapi/confidence if you want the config document itself to carry environment filters. The counter-argument, which is worth voicing in an interview, is that a manifest hides the wiring behind indirection and TypeScript cannot type-check plugin names in a JSON-ish structure, so plenty of good hapi codebases just keep an explicit ordered array in server.js. Either is defensible; what interviewers want is a candidate who has an opinion on encapsulation rather than one who dumps sixty routes into a single file.
// api/candidates/index.js
exports.plugin = {
name: 'api-candidates',
version: '1.0.0',
dependencies: ['app-db', 'app-auth'],
register: async (server) => {
server.route(require('./routes'));
server.method('candidates.byId', require('./repo').byId, {
cache: { expiresIn: 30000, generateTimeout: 2000 }
});
server.ext('onPreResponse', stripInternalFields, { sandbox: 'plugin' });
}
};
// manifest.js
module.exports = {
server: {
port: process.env.PORT || 3000,
routes: { validate: { options: { stripUnknown: true } }, payload: { maxBytes: 1048576 } }
},
register: {
plugins: [
{ plugin: './plugins/db' },
{ plugin: './plugins/logger' },
{ plugin: './plugins/auth' },
{ plugin: './api/candidates', routes: { prefix: '/api/v1/candidates' } },
{ plugin: './api/jobs', routes: { prefix: '/api/v1/jobs' } },
{ plugin: './api/admin', routes: { prefix: '/internal' } }
]
}
};
// index.js
const Glue = require('@hapi/glue');
const server = await Glue.compose(require('./manifest'), { relativeTo: __dirname });
await server.start();
Q25How do you add structured logging and per-route latency metrics to a hapi service?
IntermediateObservability
Answer
hapi emits everything through @hapi/podium event emitters, so instrumentation is subscription rather than middleware. There are two families. server.events.on('log', ...) receives server-level logs written with server.log(tags, data), and server.events.on('request', ...) receives request-scoped logs written with request.log(tags, data) plus hapi's own internal events. The request emitter is channelled: 'app' for your own request.log calls, 'error' for uncaught errors, and 'internal' for framework events such as response validation failures, and you subscribe with server.events.on({ name: 'request', channels: ['error'] }, handler).
Separately, server.events.on('response', request) fires once per completed request and is the correct place to record latency, because request.info carries received, completed and responded timestamps. Note that request.response may be a Boom object, so read the status from response.output.statusCode when isBoom is true, otherwise you will record undefined for every error. For the log transport itself, @hapi/good was the historical answer and it is no longer maintained, so a 2026 answer that recommends good is a red flag to an interviewer. hapi-pino is the standard replacement: it wires pino into the hapi event system, gives you JSON lines with a per-request child logger, and supports redaction of authorization and cookie headers, which you need before those logs reach any shared observability stack. Always key metrics on request.route.path (the template, /jobs/{slug}) rather than request.path (the concrete URL), or you will create unbounded cardinality and your metrics bill will explain the mistake for you.
// @hapi/good is end-of-life; hapi-pino is the maintained path
await server.register({
plugin: require('hapi-pino'),
options: {
level: process.env.LOG_LEVEL || 'info',
redact: ['req.headers.authorization', 'req.headers.cookie'],
logRequestStart: false,
getChildBindings: (req) => ({ reqId: req.info.id, tenant: req.app.tenantId })
}
});
// Your own tagged logs
request.log(['payment', 'retry'], { orderId, attempt });
server.log(['cron'], 'nightly reconcile finished');
// One latency metric per completed request
server.events.on('response', (request) => {
const res = request.response;
const status = res.isBoom ? res.output.statusCode : res.statusCode;
metrics.timing('http.server.duration', request.info.completed - request.info.received, {
route: request.route.path, // template, NOT request.path
method: request.method,
status
});
});
// Framework-internal failures, e.g. response schema drift
server.events.on({ name: 'request', channels: ['error', 'internal'] }, (request, event) => {
if (event.tags.includes('error')) {
metrics.increment('http.server.errors', { route: request.route.path });
}
});
Key Points
- server.events is podium; subscribe to 'log', 'request' and 'response'
- The 'request' emitter has app, error and internal channels
- Read status from response.output.statusCode when isBoom is true
- Tag metrics with request.route.path to keep cardinality bounded
Q26Why does ?filter[status]=open arrive flat in request.query, and how do you fix it?
IntermediateQuery Parsing
Answer
hapi uses Node's built-in querystring semantics by default, which produces a flat object. A URL like /jobs?filter[status]=open&sort[]=createdAt gives you { 'filter[status]': 'open', 'sort[]': 'createdAt' }, literal bracket characters in the keys, not nested structures. Express behaves differently out of the box because it bundles the qs library with extended parsing on, which is why teams migrating a client from an Express API to a hapi one discover their filter parameters silently stop working.
The fix is the server-level query.parser option: a function that receives the default-parsed object and returns whatever you want request.query to be. Point it at qs.parse and you get nesting, arrays and repeated keys. Two things to constrain when you do this.
Set an explicit depth (qs defaults to 5) and arrayLimit, because deeply nested bracket syntax is a known denial-of-service vector: a crafted query string can force the parser to allocate enormous nested structures. And decide about allowDots, since supporting both filter[status] and filter.status doubles the surface your validation must cover. After the parser runs, your Joi query schema validates the nested shape, so declare it as Joi.object with a nested object rather than string keys.
A useful companion detail: hapi replaces request.query with the validated value, so if your schema forgets a key the caller sent, that key disappears before the handler sees it. With nested query objects that failure is easy to miss, so keep the schema and the parser configuration in the same module.
const Qs = require('qs');
const Joi = require('joi');
const server = Hapi.server({
port: 3000,
query: {
parser: (query) => Qs.parse(query, { depth: 3, arrayLimit: 50, allowDots: false })
}
});
// Default hapi: { 'filter[status]': 'open', 'sort[]': 'createdAt' }
// With qs: { filter: { status: 'open' }, sort: ['createdAt'] }
server.route({
method: 'GET',
path: '/jobs',
options: {
auth: false,
validate: {
query: Joi.object({
filter: Joi.object({
status: Joi.string().valid('ACTIVE', 'CLOSED'),
city: Joi.string().max(64),
minCtc: Joi.number().integer().min(0)
}).default({}),
sort: Joi.array()
.items(Joi.string().valid('createdAt', 'salary'))
.single()
.default(['createdAt']),
page: Joi.number().integer().min(1).default(1)
})
}
},
handler: (request) => searchJobs(request.query)
});
Q27How would you implement per-route rate limiting in hapi backed by Redis?
IntermediateRate Limiting
Answer
There is no rate limiter in hapi core. The community option is hapi-rate-limit, which is fine for simple per-user or per-path limits, but most teams end up writing an extension because they want a shared Redis counter across pods and per-route budgets. The right hook is onPreAuth, before authentication and before validation, so an unauthenticated flood is rejected at the cheapest possible point in the lifecycle.
Per-route configuration goes in options.plugins, which hapi exposes at request.route.settings.plugins; that is the idiomatic way to attach arbitrary per-route settings without inventing a parallel config file. The counter itself should be a Redis INCR with a PEXPIRE set only when the counter is created, giving you a fixed window. Fixed windows allow a burst of up to twice the limit at a window boundary, so if that matters, move to a sorted-set sliding window or a token bucket implemented in a small Lua script so the read and write are atomic.
When you reject, throw Boom.tooManyRequests and set retry-after on err.output.headers, because clients and Google's crawler both honour it. Two operational points interviewers listen for. Identify the caller correctly: behind an ALB or CloudFront, request.info.remoteAddress is the proxy, so you must read the leftmost trustworthy entry of x-forwarded-for, and you should only trust that header when the connection came from your own load balancer. And fail open, not closed: if Redis is unreachable, log and let the request through, because a rate limiter that takes your API down when its own dependency blips is a worse outage than the abuse it was preventing.
server.ext('onPreAuth', async (request, h) => {
const cfg = request.route.settings.plugins.rateLimit;
if (!cfg) return h.continue;
const fwd = request.headers['x-forwarded-for'];
const ip = fwd ? fwd.split(',')[0].trim() : request.info.remoteAddress;
const bucket = Math.floor(Date.now() / cfg.windowMs);
const key = `rl:${request.route.path}:${ip}:${bucket}`;
let count;
try {
count = await redis.incr(key);
if (count === 1) await redis.pexpire(key, cfg.windowMs);
} catch (err) {
request.log(['ratelimit', 'degraded'], err.message);
return h.continue; // fail open, never fail the API
}
if (count > cfg.max) {
const boom = Boom.tooManyRequests('Rate limit exceeded');
boom.output.headers['retry-after'] = Math.ceil(cfg.windowMs / 1000);
throw boom;
}
request.app.rateLimitRemaining = cfg.max - count;
return h.continue;
});
server.route({
method: 'POST',
path: '/auth/otp',
options: { auth: false, plugins: { rateLimit: { max: 5, windowMs: 60000 } } },
handler: sendOtp
});
Key Points
- onPreAuth is the cheapest lifecycle point to reject abusive traffic
- Per-route budgets belong in options.plugins, read via request.route.settings.plugins
- Set retry-after on boom.output.headers, not on the payload
- Fail open when Redis is down; trust x-forwarded-for only behind your own proxy
Q28How do you structure a hapi test suite with @hapi/lab, and what does it give you over Jest?
IntermediateTesting
Answer
Lab is hapi's own test runner and it is deliberately small. A test file exports a script (exports.lab = Lab.script()) and pulls describe, it, before and after off it, so there are no injected globals and a file is just a module. Assertions come from @hapi/code, whose API reads as expect(x).to.equal(y) and, importantly, tracks whether every assertion was actually invoked: Code flags an assertion you constructed but never called, which catches the classic expect(res.statusCode).to.be.404 typo that silently passes in other frameworks.
Lab bundles coverage (no separate nyc), linting with -L, and, most usefully, coverage thresholds with -t, so lab -t 90 fails the build below 90 percent. It also runs each file in its own domain and reports leaked globals by default, which is how the hapi ecosystem keeps modules side-effect free. The honest comparison: Jest has better watch mode, snapshot testing, mocking and IDE integration, and most teams in 2026 write hapi tests in Jest or node:test because that is what the rest of their stack uses.
Lab's advantages are the global-leak detection, built-in thresholds and zero configuration. Either way the actual testing technique is the same and is what interviews focus on: use server.initialize() rather than start(), drive everything through server.inject(), inject credentials with the auth option to test scope enforcement without a live identity provider, and write at least one test per route for the failure path (validation 400, auth 401, scope 403, payload 413) rather than only the happy path. Testing plugins in isolation is a nice hapi-specific trick: create a bare server, register only that plugin plus stubs for its dependencies, and inject.
const Lab = require('@hapi/lab');
const { expect } = require('@hapi/code');
const { init } = require('../server');
const { describe, it, before, after } = (exports.lab = Lab.script());
describe('POST /api/v1/otp', () => {
let server;
before(async () => { server = await init(); });
after(async () => { await server.stop({ timeout: 1000 }); });
it('rejects a malformed mobile number', async () => {
const res = await server.inject({
method: 'POST', url: '/api/v1/otp', payload: { phone: '12345' }
});
expect(res.statusCode).to.equal(400);
expect(res.result.error.code).to.equal('VALIDATION_FAILED');
});
it('rate limits after five attempts in a window', async () => {
const send = () => server.inject({
method: 'POST', url: '/api/v1/otp', payload: { phone: '9810012345' }
});
for (let i = 0; i < 5; i++) await send();
const res = await send();
expect(res.statusCode).to.equal(429);
expect(res.headers['retry-after']).to.exist();
});
});
// package.json
// "test": "lab -v -L -t 90 --timeout 10000"
Q29How do you write a custom authentication scheme with server.auth.scheme, and what is payload authentication?
AdvancedAuthentication
Answer
A scheme is a factory function (server, options) that returns an object with an authenticate(request, h) method and, optionally, payload(request, h), response(request, h) and an options block. You register it with server.auth.scheme(name, factory), then create one or more configured strategies from it. Inside authenticate you either return h.authenticated({ credentials, artifacts }) to succeed, throw a Boom error to fail hard, or return h.unauthenticated(error, { credentials }) to fail in a way that still lets mode 'try' hand the request to the handler.
Pass the scheme name as the second argument to Boom.unauthorized so hapi emits a correct WWW-Authenticate header, and pass null as the message when credentials were simply absent rather than wrong, because that distinguishes 'no attempt' from 'bad attempt' when several strategies are chained on one route. The payload method is the part most candidates have never used. Set options.payload = true on the scheme (or auth: { payload: 'required' } on the route) and hapi calls it during the payload-authentication stage, after the body has been read but before validation.
That is how you verify a signature computed over the request body, which is exactly what AWS SigV4 and most partner API schemes do. Without it you would have to disable parsing and verify inside the handler, losing the ability to reject before Joi runs. Custom schemes are also where you implement mTLS client-certificate identity by reading request.raw.req.socket.getPeerCertificate(), and API-key auth backed by a cached lookup so that a hot partner does not hit your database on every call.
const Crypto = require('crypto');
const hmacScheme = (server, options) => ({
options: { payload: true }, // enable the payload stage
authenticate: async (request, h) => {
const header = request.headers.authorization;
if (!header || !header.startsWith('GS1-HMAC ')) {
throw Boom.unauthorized(null, 'GS1-HMAC'); // null = no attempt made
}
const [keyId, signature] = header.slice(9).split(':');
const client = await options.lookup(keyId);
if (!client) throw Boom.unauthorized('Unknown key', 'GS1-HMAC');
const base = [request.method, request.path, request.headers['x-date']].join('|');
const expected = Crypto.createHmac('sha256', client.secret).update(base).digest('hex');
if (!safeCompare(signature, expected)) throw Boom.unauthorized('Bad signature', 'GS1-HMAC');
return h.authenticated({
credentials: { clientId: client.id, scope: client.scopes },
artifacts: { keyId }
});
},
payload: async (request, h) => {
const digest = Crypto.createHash('sha256').update(request.payload).digest('base64');
if (digest !== request.headers['x-content-sha256']) {
throw Boom.unauthorized('Payload digest mismatch', 'GS1-HMAC');
}
return h.continue;
}
});
server.auth.scheme('gs-hmac', hmacScheme);
server.auth.strategy('partner', 'gs-hmac', { lookup: loadApiKey });
server.route({
method: 'POST',
path: '/partner/leads',
options: { auth: { strategy: 'partner', payload: 'required' } },
handler: ingestLead
});
Key Points
- authenticate returns h.authenticated, throws Boom, or returns h.unauthenticated
- Boom.unauthorized(null, scheme) means no credentials were offered at all
- The payload stage verifies a signature over the body before validation runs
- h.unauthenticated is what makes mode 'try' usable with a custom scheme
Q30How do you make a hapi service shut down gracefully under Kubernetes?
AdvancedProduction
Answer
server.stop({ timeout }) stops accepting new connections, waits up to timeout milliseconds for in-flight requests to complete, then force-closes anything remaining. Around that, hapi runs onPreStop before the listener closes and onPostStop after everything has drained, which is where you close database pools, Redis clients and Kafka producers. The mistake almost every team makes is treating SIGTERM as the moment to stop serving.
It is not. When Kubernetes deletes a pod it sends SIGTERM and simultaneously starts removing the pod from Service endpoints, and those two things race: kube-proxy and your ingress controller may keep sending traffic for several seconds after your process decided to die. The correct sequence is to flip an internal shuttingDown flag in onPreStop so the readiness probe starts returning 503, sleep long enough for endpoint propagation (five seconds is a common figure, measure yours), and only then let server.stop() drain.
Your stop timeout must be comfortably below terminationGracePeriodSeconds in the pod spec, or the kubelet SIGKILLs you mid-drain and you lose in-flight requests; 25 seconds of stop timeout under a 30 second grace period is a reasonable pairing. Two more details. Keep the liveness probe on a separate path from readiness and never make liveness depend on the database, otherwise a brief Postgres blip restarts every pod simultaneously. And handle SIGINT identically so local Ctrl-C exercises the same path your production rollout uses, which is the only reliable way to notice that your Kafka producer never disconnects.
let shuttingDown = false;
server.route({
method: 'GET',
path: '/readyz',
options: { auth: false, tags: ['ops'] },
handler: (request, h) =>
shuttingDown ? h.response({ ready: false }).code(503) : h.response({ ready: true })
});
// Liveness stays on a separate path and never touches the database
server.route({
method: 'GET', path: '/livez',
options: { auth: false }, handler: () => ({ alive: true })
});
server.ext('onPreStop', async (srv) => {
shuttingDown = true; // readiness starts failing
srv.log(['shutdown'], 'draining, waiting for endpoint propagation');
await new Promise((resolve) => setTimeout(resolve, 5000));
});
server.ext('onPostStop', async () => {
await Promise.allSettled([db.destroy(), redis.quit(), producer.disconnect()]);
});
const shutdown = async (signal) => {
try {
server.log(['shutdown'], signal);
await server.stop({ timeout: 25 * 1000 }); // < terminationGracePeriodSeconds
process.exit(0);
} catch (err) {
console.error(err);
process.exit(1);
}
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Q31A hapi pod's RSS climbs steadily and p99 latency drifts up. How do you diagnose it?
AdvancedPerformance
Answer
Separate the two symptoms first, because they have different causes. Rising RSS with flat event loop delay is a leak or an unbounded buffer; rising p99 with flat RSS is event loop starvation or a slow dependency. Instrument both before guessing: perf_hooks.monitorEventLoopDelay gives you a real histogram of loop lag, and process.memoryUsage() gives heapUsed, external and arrayBuffers, which matter because Buffers live outside the V8 heap and a heap snapshot will not show them.
In hapi specifically there is a short list of usual suspects. First, payload.maxBytes raised globally: with output 'data' every concurrent request holds its whole body in memory, so a 50 MB limit and 40 concurrent uploads is 2 GB of Buffers and no heap snapshot will look wrong because it is all external memory. Second, output: 'file' uploads whose temp files are never unlinked, which fills the ephemeral volume rather than memory but presents as mysterious pod evictions.
Third, objects parked on server.app or on a closure inside a plugin that accumulate per request. Fourth, @hapi/catbox-memory used as the default cache with no maxByteSize, which will happily grow until the process dies. Fifth, an event listener registered per request instead of once, which podium will report as a growing listener count.
For the latency side, the usual cause is synchronous CPU on the request path: JSON.stringify of a large array, a synchronous crypto call, or Joi validating a deeply nested schema on every response because someone left options.response.sample at 100. Capture a CPU profile with node --cpu-prof or clinic flame under load, and run node with --heapsnapshot-signal=SIGUSR2 so you can take a snapshot from a live pod without redeploying.
const { monitorEventLoopDelay } = require('node:perf_hooks');
const loop = monitorEventLoopDelay({ resolution: 20 });
loop.enable();
setInterval(() => {
const mem = process.memoryUsage();
metrics.gauge('eventloop.p99_ms', loop.percentile(99) / 1e6);
metrics.gauge('eventloop.mean_ms', loop.mean / 1e6);
metrics.gauge('mem.heap_used_mb', mem.heapUsed / 1048576);
metrics.gauge('mem.external_mb', mem.external / 1048576); // Buffers live here
metrics.gauge('mem.rss_mb', mem.rss / 1048576);
loop.reset();
}, 10000).unref();
// hapi-specific things to check, in order of hit rate:
// 1. routes.payload.maxBytes raised globally with output: 'data'
// 2. output: 'file' uploads never unlinked from os.tmpdir()
// 3. per-request objects retained on server.app or a plugin closure
// 4. @hapi/catbox-memory as the cache with no maxByteSize cap
// 5. server.events.on(...) called inside a handler instead of at register time
// 6. options.response.sample left at 100 on a hot route
// Live capture without a redeploy:
// node --heapsnapshot-signal=SIGUSR2 --cpu-prof server.js
// kill -USR2 <pid>
Key Points
- Buffers count as external memory, not heap, so snapshots can look clean
- monitorEventLoopDelay separates 'slow dependency' from 'starved loop'
- Global maxBytes with output 'data' is the most common hapi memory bug
- catbox-memory without maxByteSize grows until the process dies
Q32How do you propagate request context and OpenTelemetry traces through a hapi service?
AdvancedObservability
Answer
There are two problems and they need different tools. Tracing spans come from OpenTelemetry: start the NodeSDK before anything else in the process, register the auto-instrumentations bundle plus @opentelemetry/instrumentation-hapi, and you get a server span per request with child spans for route handlers and lifecycle extensions, with W3C traceparent propagation into outgoing http, pg, ioredis and kafkajs calls for free. Ambient context, meaning 'every log line in every module should carry the request id and tenant without threading a parameter through twelve functions', comes from AsyncLocalStorage.
The hapi-specific trap is choosing between als.run() and als.enterWith() in an onRequest extension. als.run(store, callback) only keeps the store alive inside that callback, and hapi awaits your extension and then continues the lifecycle in its own continuation, so by the time the handler runs the store is gone. als.enterWith(store) mutates the current execution context so the store propagates forward through the rest of the request's async chain, which is what you want here. Set it in onRequest, enrich it in onPostAuth once credentials exist, and read it from a logger helper anywhere. Two production notes.
Keep the store small and never put the request object in it, because a retained store keeps the whole request graph alive. And on the tracing side, decide your sampling deliberately: a parent-based sampler with a low ratio plus a rule that always samples 5xx gives you useful traces without paying to store the boring ones. Attach request.info.id as a span attribute so a support ticket carrying a request id can be joined to a trace.
require('./tracing'); // NodeSDK, first import in the process
const { AsyncLocalStorage } = require('node:async_hooks');
const Otel = require('@opentelemetry/api');
const als = new AsyncLocalStorage();
server.ext('onRequest', (request, h) => {
const span = Otel.trace.getActiveSpan();
// enterWith, NOT run(): the lifecycle continues outside this callback
als.enterWith({
reqId: request.info.id,
traceId: span ? span.spanContext().traceId : undefined,
tenantId: null
});
if (span) span.setAttribute('gs.request_id', request.info.id);
return h.continue;
});
server.ext('onPostAuth', (request, h) => {
const store = als.getStore();
const creds = request.auth.credentials;
if (store && creds) store.tenantId = creds.tenantId;
const span = Otel.trace.getActiveSpan();
if (span && creds) span.setAttributes({ 'enduser.id': creds.userId, 'gs.tenant': creds.tenantId });
return h.continue;
});
server.ext('onPreResponse', (request, h) => {
const res = request.response;
const span = Otel.trace.getActiveSpan();
if (span && res.isBoom && res.output.statusCode >= 500) {
span.recordException(res);
span.setStatus({ code: Otel.SpanStatusCode.ERROR });
}
return h.continue;
});
// Any module, no parameter threading:
const log = (msg, data) => pino.info({ ...data, ...(als.getStore() || {}) }, msg);
Q33You have inherited a hapi 16 codebase. What actually breaks on the way to hapi 21?
AdvancedMigration
Answer
The v16 to v17 jump is the expensive one and everything after it is comparatively mechanical. In v17 the reply() callback was removed entirely: handlers, extensions, pre methods and auth schemes all became async functions that return a value or throw, reply.continue became h.continue, reply(err) became throw err, and reply(x).code(201).takeover() became h.response(x).code(201).takeover(). Route configuration moved from route.config to route.options (config still works as an alias but every modern example uses options). server.connection() disappeared, one server is one connection now, and multi-connection setups became separate server instances.
Plugin registration went from a callback signature with an attributes object to exports.plugin = { name, version, register } with an async register. In v18 every core module moved to the @hapi/ npm scope, so require('hapi') becomes require('@hapi/hapi') and the same for boom, joi, inert, vision, lab, code, wreck and hoek. Joi then left the scope again at its own v17 release, so the correct dependency in 2026 is plain 'joi' and any codebase still on @hapi/joi is on an abandoned fork.
In v19 multipart payload parsing became opt-in, which is a silent breakage: forms stop parsing with no error until you add payload: { multipart: true }. v20 and v21 are mostly engine bumps that drop older Node versions plus modernised catbox and podium interfaces, so third-party plugins pinned to an old catbox are the thing that actually blocks you. Practical advice for the interview: do it in stages with the test suite as the contract, upgrade one major at a time, and audit your plugin dependencies first because an unmaintained plugin, not hapi itself, is usually what strands a migration.
// v16 -> v17: the callback is gone
// before:
// handler: function (request, reply) { reply({ ok: true }).code(201); }
// after:
handler: async (request, h) => h.response({ ok: true }).code(201)
// v16 -> v17: route.config became route.options; server.connection() removed
// v16 -> v17: exports.register + exports.register.attributes became exports.plugin
// v17 -> v18: everything moved under the @hapi/ scope
// require('hapi') -> require('@hapi/hapi')
// require('boom') -> require('@hapi/boom')
// require('@hapi/joi') -> require('joi') // joi left the scope at joi v17
// v18 -> v19: multipart parsing became opt-in and fails silently without it
options: { payload: { multipart: { output: 'stream' } } }
// v19 -> v21: Node engine bumps plus modernised catbox / podium interfaces.
// Unmaintained third-party plugins pinned to old catbox are the real blocker.
// Greps that find most of the v16 work:
// grep -rn "function (request, reply)" src/
// grep -rn "server.connection(" src/
// grep -rn "config: {" src/routes/
Key Points
- v17 removed reply(); everything is async/await with the h toolkit
- route.config became route.options and server.connection() was removed
- v18 moved core modules to @hapi/; joi later moved back to plain 'joi'
- v19 made multipart parsing opt-in, which breaks forms with no error
- Unmaintained third-party plugins block upgrades more often than hapi does
Q34How would you tune a hapi service that has to hold 5,000 requests per second per pod?
AdvancedPerformance
Answer
Start by accepting what hapi is. Its per-request overhead is higher than Fastify's because the lifecycle does more work for you, so tuning means removing work you are not using rather than expecting the framework to get faster. Concretely: turn off or sample response validation, since options.response runs a full Joi pass over every outgoing body and at sample: 100 on a hot route it can dominate your CPU profile; sample: 1 or 2 keeps the drift alarm without the cost.
Keep validation schemas shallow and compile-friendly, and prefer Joi.object with known keys over recursive or alternatives-heavy schemas. Do not gzip in Node when nginx, an ALB or CloudFront is already in front of you; set compression: false or raise minBytes so small JSON bodies skip it. Bound everything: routes.timeout.server, payload.timeout, and an explicit timeout on every outbound call, because in Node a slow dependency turns into unbounded queueing at the event loop rather than a clean failure.
Cache with @hapi/catbox-redis and use staleIn plus staleTimeout so a cache refresh never appears in your p99. Push anything that is not required for the response off the request path into a queue. On the process side, Node is single-threaded, so throughput per host means one process per core, either PM2 in cluster mode or one pod per core with a horizontal autoscaler; oversubscribing cores makes the event loop delay histogram worse, not better. Finally, measure before each change: monitorEventLoopDelay plus a CPU profile under real load will usually show that the framework is not your problem, JSON serialisation of an over-fetched database row is.
const server = Hapi.server({
port: 3000,
host: '0.0.0.0',
compression: { minBytes: 4096 }, // or false behind nginx / ALB
debug: false, // no console debug output in prod
routes: {
payload: { maxBytes: 1048576, timeout: 10000 },
timeout: { server: 8000, socket: false },
validate: { options: { abortEarly: true, stripUnknown: true } },
response: { sample: 2, failAction: 'log' }, // schema drift alarm, not a tax
cache: { privacy: 'private' },
security: { hsts: true, noSniff: true, xframe: 'deny' }
},
cache: [{
name: 'redis',
provider: {
constructor: require('@hapi/catbox-redis').Engine,
options: { host: process.env.REDIS_HOST, partition: 'gs' }
}
}]
});
// Stale-while-revalidate keeps upstream latency out of your p99
server.method('feed.forUser', buildFeed, {
cache: {
cache: 'redis', segment: 'feed',
expiresIn: 120000, staleIn: 90000, staleTimeout: 80, generateTimeout: 4000
},
generateKey: (userId) => `u:${userId}`
});
// One process per core; do not oversubscribe
// pm2 start server.js -i max --max-memory-restart 700M
Key Points
- Sample response validation instead of running it on every response
- Offload gzip to the proxy; Node compression is pure CPU on the hot path
- Bound server timeout, payload timeout and every outbound call
- catbox-redis with staleIn/staleTimeout keeps refreshes out of p99
- One Node process per core; oversubscription worsens event loop delay
Q35Would you keep a hapi service or migrate it to Fastify or NestJS, and how would you do it safely?
AdvancedArchitecture
Answer
This is a judgement question and the wrong answer is an unconditional one in either direction. The case for staying: hapi is stable, actively maintained, and its declarative route configuration plus boot-time conflict detection genuinely reduce a class of production bugs. If the service works, the team knows it, and the plugin dependencies are maintained, a rewrite buys you nothing a customer can see.
The case for moving: hiring. In India the Node job market is overwhelmingly Express, NestJS and increasingly Fastify, so a hapi codebase raises your ramp-up cost for every new joiner, and the third-party ecosystem is thin enough that you will write in-house what other frameworks get from a package. Community activity around hapi has been modest since the original maintainers stepped back, and a plugin you depend on going unmaintained is a real risk that shows up during a Node upgrade rather than on a normal sprint.
If you decide to move, do it as a strangler, never as a rewrite. Put the new service behind the same ingress path, route one endpoint at a time, and use @hapi/h2o2 inside the existing hapi app to proxy migrated paths outward so rollback is a config change rather than a deploy. Keep the contract tests running against both implementations, and migrate the least risky, highest-traffic read endpoints first so you learn the operational differences before you touch anything that writes money. Have an explicit stopping rule: if half the endpoints have moved and the remaining half are stable, it is completely reasonable to stop and run both indefinitely rather than finish for tidiness.
const H2o2 = require('@hapi/h2o2');
await server.register(H2o2);
// Strangler: paths already rebuilt on the new stack are proxied out of hapi.
// Rollback is deleting this route, not redeploying the new service.
server.route({
method: '*',
path: '/api/v1/search/{p*}',
options: {
auth: false, // the target re-validates the bearer token
payload: { parse: false, output: 'stream', maxBytes: 5 * 1024 * 1024 },
handler: {
proxy: {
host: 'search-v2.internal',
port: 8080,
protocol: 'http',
passThrough: true, // forward cookies and headers
xforward: true, // add x-forwarded-* for the target
timeout: 8000
}
}
}
});
// Shadow traffic first: mirror a percentage without using the response
server.ext('onPostHandler', (request, h) => {
if (request.route.path === '/api/v1/search/{p*}' && Math.random() < 0.05) {
Wreck.request(request.method, 'http://search-v2.internal:8080' + request.path)
.then((res) => compareShapes(request.response.source, res))
.catch(() => {});
}
return h.continue;
});
Frequently Asked Questions
What does a hapi developer earn in India in 2026?
Roughly ₹6-20 LPA, with the band tracking Node.js backend experience rather than hapi itself. Two to four years of Node experience with hapi in production tends to land ₹8-13 LPA at product companies in Bengaluru, Pune and the NCR, while senior engineers who own auth, caching and observability on a hapi service reach the high teens. Enterprise and services employers such as Walmart Global Tech, TCS, Infosys, Cognizant and HCLTech account for a large share of hapi openings because their existing Node estates were built on it, and their bands skew slightly lower than product startups at the same experience level. Nobody is paid a premium for hapi specifically, so present yourself as a Node backend engineer who knows hapi deeply.
Is hapi still worth learning in 2026?
Worth learning deliberately, not worth choosing for a new greenfield project unless your team already knows it. The realistic reason to learn hapi is that a job you want involves an existing hapi codebase, and there are a meaningful number of those in India inside large enterprise Node estates. The framework is stable and the concepts transfer well: its lifecycle, declarative route configuration and plugin encapsulation make you a better Fastify and NestJS engineer afterwards. What you should not do is spend three months on hapi as your only backend framework. Learn Express or Fastify as your default, add NestJS if you want the enterprise track, and pick up hapi in a focused week or two when a role calls for it.
How long does it take to prepare for a hapi interview?
If you already know Node and Express well, one to two weeks of focused work is enough. Spend the first three days building a small API with real route options: Joi validation with a custom failAction, a JWT strategy with scopes, an onPreResponse error envelope and a catbox-cached server method. Spend the next three writing tests with server.inject including auth injection and the 400, 401, 403 and 413 paths. Use the remaining time on the lifecycle order, plugin registration and dependencies, and the production topics: graceful shutdown, payload limits and observability. If you are new to Node entirely, budget six to eight weeks, because most hapi interview failures are Node failures (event loop, streams, async error handling) wearing a hapi costume.
What is the difference between a fresher and an experienced hapi interview?
Freshers are asked to demonstrate the mental model: build a route with validation, explain what h.response does, use Boom for errors, register a plugin, write a server.inject test. Getting the lifecycle order roughly right and knowing that validation runs after authentication puts you ahead of most candidates at that level. Experienced candidates get the failure modes instead. Expect questions on why a Joi message went generic in production, what happens to an unhandled non-Boom error, how server.method caching behaves when generateTimeout fires, how you drain in-flight requests during a Kubernetes rollout, and how you would debug rising RSS on a pod. At three years and above, at least one round will be a design or code-review discussion rather than recall.
How does hapi compare with Express, Fastify and NestJS?
Express is the lowest-common-denominator middleware framework: tiny core, enormous ecosystem, no opinions, and you assemble validation, auth and error handling yourself. Fastify is the throughput choice, with schema-based serialisation making it materially faster than both Express and hapi on JSON workloads, plus a plugin and encapsulation model that is philosophically close to hapi's. NestJS is the enterprise structure choice: TypeScript-first, dependency injection, and the deepest ecosystem for large teams. hapi sits between Express and NestJS: more built-in than Express (validation, auth, caching, cookies, payload handling all in core) but without DI or a heavy class-and-decorator layer. For a new service in India in 2026, most teams pick NestJS for size or Fastify for speed; hapi wins when you value declarative, auditable route configuration.
What should I build to show hapi skill in an interview?
One small service that hits every surface an interviewer probes, not three toy CRUD apps. A good target is an OTP-and-jobs API: a rate-limited OTP endpoint using an onPreAuth Redis extension, a JWT strategy with dynamic tenant scopes, Joi validation with a custom failAction that returns field-level codes, a single onPreResponse error envelope, one endpoint using a catbox-cached server method with staleIn and staleTimeout, one streaming upload route with a raised maxBytes, a webhook route with parse: false and HMAC verification, and graceful shutdown wired to SIGTERM with readiness flipping first. Add a lab or Jest suite driven entirely by server.inject that covers 400, 401, 403, 413 and 429. That repository answers about fifteen of the questions on this page before you say a word.
Introduction
hapi began at Walmart Labs as the framework built to survive Black Friday traffic, and it still occupies a distinct corner of the Node.js world: configuration over middleware. There is no app.use() chain. A route is a plain object with method, path, handler and an options block that declares validation, authentication, caching, payload limits, CORS and timeouts in one place, and the framework enforces those declarations for you rather than trusting you to order middleware correctly. Since version 17 every handler and extension point is async/await, the old reply() callback is gone, and every core module lives under the @hapi/ npm scope. Version 21 is the current major line and targets modern Node LTS releases.
Interviewers rarely ask you to define hapi. They ask what runs between onRequest and onPreResponse, why a Joi failure returns a generic message in production, what Boom.badImplementation hides from the client, how server.method caches through catbox with staleIn and generateTimeout, and how server.decorate behaves differently across the server, request, toolkit and handler targets. hapi roles in India cluster around maintained enterprise Node estates rather than greenfield work: Walmart-lineage platforms, Auth0 codebases now under Okta, and the large services firms running long-lived client systems. Pay sits around ₹6-20 LPA, with the top of that band going to engineers who can debug payload buffering and event loop lag, not just wire routes.
This guide covers 35 hapi interview questions, ordered from fundamentals to production architecture, with runnable code on most of them. The basic section fixes the mental model: server construction, route configuration, the h toolkit, Boom errors, Joi validation, cookies and payload limits. The intermediate section goes where the real work happens: lifecycle extensions, route prerequisites, catbox caching, JWT scopes, raw webhook payloads, streaming uploads, rate limiting and testing. The advanced section covers what senior offers actually turn on: custom auth schemes, graceful shutdown under Kubernetes, memory and event loop diagnosis, trace context propagation, the v17 to v21 migration trail, and when to stop investing in hapi.
Ready to practice Hapi interviews?
Don't just read, practice these Hapi questions live with an AI interviewer that asks follow-ups and scores your answers.