TL;DR
http-errors is the standard HTTP error factory — creates error objects with status codes, used by Express and Koa internally. @hapi/boom is the Hapi framework's error utility — rich error objects with data payloads, error decoration, and consistent error response format. http-status-codes is a TypeScript enum/constant library for HTTP status codes — type-safe status code references, reason phrases, no error object creation. In 2026: http-errors for Express/Koa middleware, @hapi/boom for structured API error responses, http-status-codes for type-safe status constants.
Key Takeaways
- http-errors: ~30M weekly downloads — Express/Koa core, creates Error objects with statusCode
- @hapi/boom: ~5M weekly downloads — Hapi ecosystem, rich error payloads, consistent format
- http-status-codes: ~3M weekly downloads — TypeScript enums for status codes, zero error creation
- http-errors and @hapi/boom create error objects; http-status-codes is just constants
- http-errors is the simplest —
createError(404, "Not found") - @hapi/boom provides the richest error format with data, headers, and decoration
http-errors
http-errors — standard HTTP error factory:
Basic usage
import createError from "http-errors"
// Create errors by status code:
throw createError(404, "Package not found")
// → { status: 404, statusCode: 404, message: "Package not found", expose: true }
throw createError(500, "Database connection failed")
// → { status: 500, statusCode: 500, message: "Database connection failed", expose: false }
// Named constructors:
throw new createError.NotFound("Package not found")
throw new createError.BadRequest("Invalid package name")
throw new createError.Unauthorized("Authentication required")
throw new createError.Forbidden("Insufficient permissions")
throw new createError.InternalServerError("Something went wrong")
Express middleware
import express from "express"
import createError from "http-errors"
const app = express()
app.get("/api/packages/:name", async (req, res, next) => {
try {
const pkg = await db.findPackage(req.params.name)
if (!pkg) {
throw createError(404, `Package "${req.params.name}" not found`)
}
res.json(pkg)
} catch (err) {
next(err)
}
})
// Error handler:
app.use((err, req, res, next) => {
if (createError.isHttpError(err)) {
res.status(err.statusCode).json({
error: err.message,
statusCode: err.statusCode,
})
} else {
res.status(500).json({ error: "Internal server error" })
}
})
Error properties
import createError from "http-errors"
// Add custom properties:
const error = createError(422, "Validation failed", {
fields: { name: "Required", version: "Must be semver" },
})
// error.fields → { name: "Required", version: "Must be semver" }
// expose property — controls if message is shown to client:
// 4xx errors: expose = true (client errors, safe to show)
// 5xx errors: expose = false (server errors, hide details)
const clientError = createError(400, "Bad request")
clientError.expose // true
const serverError = createError(500, "Database crashed")
serverError.expose // false
// Check if an error is an HTTP error:
createError.isHttpError(clientError) // true
createError.isHttpError(new Error()) // false
With Koa
import Koa from "koa"
import createError from "http-errors"
const app = new Koa()
app.use(async (ctx) => {
const pkg = await db.findPackage(ctx.params.name)
if (!pkg) {
// Koa natively understands http-errors:
ctx.throw(404, "Package not found")
// Equivalent to: throw createError(404, "Package not found")
}
ctx.body = pkg
})
@hapi/boom
@hapi/boom — rich HTTP error objects:
Basic usage
import Boom from "@hapi/boom"
// Named error factories:
throw Boom.notFound("Package not found")
// → { statusCode: 404, error: "Not Found", message: "Package not found" }
throw Boom.badRequest("Invalid package name")
throw Boom.unauthorized("Invalid token")
throw Boom.forbidden("Admin access required")
throw Boom.conflict("Package already exists")
throw Boom.tooManyRequests("Rate limit exceeded")
Error with data payload
import Boom from "@hapi/boom"
// Attach data to errors:
throw Boom.badRequest("Validation failed", {
fields: [
{ field: "name", message: "Required" },
{ field: "version", message: "Must be valid semver" },
],
})
// Output:
// {
// statusCode: 400,
// error: "Bad Request",
// message: "Validation failed",
// data: { fields: [...] }
// }
// The output() method gives a consistent response format:
const err = Boom.notFound("Package not found", { name: "nonexist" })
const response = err.output
// response.statusCode → 404
// response.payload → { statusCode: 404, error: "Not Found", message: "Package not found" }
// response.headers → {}
Custom headers
import Boom from "@hapi/boom"
// Rate limiting error with headers:
const error = Boom.tooManyRequests("Rate limit exceeded")
error.output.headers["Retry-After"] = "60"
error.output.headers["X-RateLimit-Limit"] = "100"
error.output.headers["X-RateLimit-Remaining"] = "0"
// Authentication error with WWW-Authenticate:
const authError = Boom.unauthorized("Invalid token", "Bearer", {
realm: "api",
error: "invalid_token",
})
// Automatically sets: WWW-Authenticate: Bearer realm="api", error="invalid_token"
Error decoration
import Boom from "@hapi/boom"
// Decorate existing errors:
try {
await db.query("SELECT * FROM packages")
} catch (dbError) {
// Wrap database error as HTTP error:
throw Boom.internal("Database query failed", dbError)
// Original error preserved in err.data
}
// Check if error is a Boom error:
Boom.isBoom(error) // true
Boom.isBoom(new Error()) // false
// Convert any error to Boom:
const regularError = new Error("Something broke")
const boomError = Boom.boomify(regularError, { statusCode: 502 })
Express integration
import express from "express"
import Boom from "@hapi/boom"
const app = express()
app.get("/api/packages/:name", async (req, res, next) => {
try {
const pkg = await db.findPackage(req.params.name)
if (!pkg) throw Boom.notFound(`Package "${req.params.name}" not found`)
res.json(pkg)
} catch (err) {
next(err)
}
})
// Boom error handler:
app.use((err, req, res, next) => {
if (Boom.isBoom(err)) {
const { output } = err
// Set custom headers:
Object.entries(output.headers).forEach(([key, value]) => {
res.setHeader(key, value)
})
res.status(output.statusCode).json(output.payload)
} else {
res.status(500).json({ error: "Internal server error" })
}
})
http-status-codes
http-status-codes — status code constants:
Status code enums
import { StatusCodes, ReasonPhrases, getReasonPhrase } from "http-status-codes"
// Type-safe status codes:
StatusCodes.OK // 200
StatusCodes.CREATED // 201
StatusCodes.BAD_REQUEST // 400
StatusCodes.UNAUTHORIZED // 401
StatusCodes.NOT_FOUND // 404
StatusCodes.INTERNAL_SERVER_ERROR // 500
StatusCodes.TOO_MANY_REQUESTS // 429
// Reason phrases:
ReasonPhrases.OK // "OK"
ReasonPhrases.NOT_FOUND // "Not Found"
ReasonPhrases.INTERNAL_SERVER_ERROR // "Internal Server Error"
// Get phrase from code:
getReasonPhrase(404) // "Not Found"
getReasonPhrase(200) // "OK"
With Express
import express from "express"
import { StatusCodes, ReasonPhrases } from "http-status-codes"
const app = express()
app.get("/api/packages/:name", async (req, res) => {
const pkg = await db.findPackage(req.params.name)
if (!pkg) {
return res.status(StatusCodes.NOT_FOUND).json({
error: ReasonPhrases.NOT_FOUND,
message: `Package "${req.params.name}" not found`,
})
}
res.status(StatusCodes.OK).json(pkg)
})
app.post("/api/packages", async (req, res) => {
const pkg = await db.createPackage(req.body)
res.status(StatusCodes.CREATED).json(pkg)
})
Type-safe responses
import { StatusCodes, getReasonPhrase, getStatusCode } from "http-status-codes"
// Convert between codes and phrases:
getReasonPhrase(StatusCodes.NOT_FOUND) // "Not Found"
getStatusCode("Not Found") // 404
// Type checking:
function sendError(res: Response, code: StatusCodes, message: string) {
res.status(code).json({
statusCode: code,
error: getReasonPhrase(code),
message,
})
}
sendError(res, StatusCodes.BAD_REQUEST, "Invalid input")
sendError(res, StatusCodes.UNAUTHORIZED, "Token expired")
Combining with http-errors
import createError from "http-errors"
import { StatusCodes } from "http-status-codes"
// Use status codes constants with http-errors:
throw createError(StatusCodes.NOT_FOUND, "Package not found")
throw createError(StatusCodes.BAD_REQUEST, "Invalid name")
throw createError(StatusCodes.CONFLICT, "Already exists")
// Better than magic numbers:
// throw createError(404, "Not found") ← what's 404?
// throw createError(StatusCodes.NOT_FOUND, "Not found") ← clear!
Feature Comparison
| Feature | http-errors | @hapi/boom | http-status-codes |
|---|---|---|---|
| Creates error objects | ✅ | ✅ | ❌ (constants only) |
| Named constructors | ✅ | ✅ | ❌ |
| Data payload | ⚠️ (custom props) | ✅ (structured) | ❌ |
| Custom headers | ❌ | ✅ | ❌ |
| Error wrapping | ❌ | ✅ (boomify) | ❌ |
| Status code enums | ❌ | ❌ | ✅ |
| Reason phrases | ❌ | ✅ (in output) | ✅ |
| expose flag | ✅ | ❌ (all exposed) | ❌ |
| TypeScript | ✅ | ✅ | ✅ |
| Used by | Express, Koa | Hapi | Any framework |
| Weekly downloads | ~30M | ~5M | ~3M |
When to Use Each
Use http-errors if:
- Using Express or Koa (they understand http-errors natively)
- Want simple error creation with status codes
- Need the expose flag (hide 5xx details from clients)
- Want the most lightweight error factory
Use @hapi/boom if:
- Need structured error responses with data payloads
- Want custom headers on error responses (rate limiting, auth)
- Building APIs that need consistent error format
- Need to wrap/decorate existing errors
Use http-status-codes if:
- Want type-safe status code constants (no magic numbers)
- Need reason phrase lookups
- Combining with other error libraries (http-errors + status constants)
- Want IDE autocomplete for HTTP status codes
Building a Consistent Error Response Format
One of the most common pain points in Node.js API development is inconsistent error response shapes. A 404 from one endpoint returns { message: "Not found" }, another returns { error: "Package not found" }, and a validation error returns a flat string. Clients end up with defensive parsing code to handle every possible error shape. Both http-errors and @hapi/boom solve this differently.
http-errors doesn't enforce a response shape — it creates error objects with statusCode and message, but your error-handling middleware decides how to serialize them to JSON. This gives maximum flexibility but means each team invents their own convention. The most common pattern is a middleware that checks createError.isHttpError(err) and serializes { statusCode: err.statusCode, message: err.expose ? err.message : "Internal server error" }. The expose flag is the key detail: 4xx errors expose their message (it's safe to tell the client "Package not found"), while 5xx errors suppress the message to avoid leaking internal implementation details.
@hapi/boom enforces a specific response format via err.output.payload: always { statusCode: number, error: string, message: string }. The error field contains the HTTP reason phrase ("Not Found", "Bad Request"), message is your custom message, and statusCode is the numeric code. This consistent shape means your API clients can write a single error parser that handles every error response. For APIs with multiple consumers (mobile apps, third-party integrations, internal services), the predictable structure reduces integration friction. Adding data to a Boom error puts it in err.output.payload.data when you call err.reformat(true).
Framework Error Handling Patterns with Hono and Fastify
Express and Koa are the traditional targets for http-errors, but the Node.js framework landscape in 2026 includes Hono, Fastify, and Elysia. http-errors works with any framework that supports error-passing middleware, but the integration ergonomics vary.
Fastify has its own error handling system built around fastify.setErrorHandler() and throws HTTP errors using Fastify's reply.internalServerError() / reply.notFound() methods. These produce Fastify's native error format, not http-errors objects. You can still use http-errors in Fastify by throwing them inside route handlers and catching them in a custom error handler, but it adds a translation layer. @hapi/boom is similarly framework-specific — it was designed for the Hapi ecosystem and works best there.
Hono has a built-in HTTPException class (throw new HTTPException(404, { message: "Not found" })) that serves the same purpose as http-errors without the dependency. For projects using Hono exclusively, http-errors adds little value. The http-status-codes library is the most portable of the three precisely because it contains no error objects — StatusCodes.NOT_FOUND is just the number 404, which works in any framework's error-throwing mechanism.
Using http-status-codes for Type-Safe API Client Code
http-status-codes isn't only useful in server-side handlers — it's equally valuable in API client code, test assertions, and middleware checks. When writing tests that verify error responses, expect(response.status).toBe(StatusCodes.UNPROCESSABLE_ENTITY) is clearer than expect(response.status).toBe(422). The numeric code 422 is meaningful to HTTP veterans but opaque to developers unfamiliar with the full status code space; the named constant is self-documenting.
For API client libraries that parse response status codes, the isClientError() and isServerError() utility functions (checking if a code falls in the 4xx or 5xx range respectively) simplify response classification without hardcoding magic number comparisons like status >= 400 && status < 500. The library is 3KB minified, has zero dependencies, and ships with both CommonJS and ESM builds, making it safe to include in browser-side code alongside server-side usage.
Internationalization of Error Messages
Production APIs that serve international audiences often need to return error messages in the user's language. None of the three libraries handles i18n natively — error message strings are hardcoded English in all three. The integration pattern for multilingual error messages varies by library. With http-errors, the standard approach is to pass a message key rather than a human-readable string in development, then resolve the key to a localized message in the error-handling middleware using req.acceptsLanguages() and an i18n library like i18next. The expose flag still works correctly — 4xx error keys are exposed to the client for translation lookup, while 5xx keys are suppressed.
@hapi/boom's consistent output format makes i18n slightly cleaner: the message field in output.payload can hold a translation key ("errors.packageNotFound") that clients translate client-side, or the error handler can resolve it server-side before sending the response. The data field is particularly useful for error-specific context — a 422 Unprocessable Entity error for form validation can include data: { field: "name", key: "validation.required" } alongside the generic message, giving the client everything it needs to display a localized, field-specific error without additional API calls.
Error Serialization for API Documentation and OpenAPI
Well-structured HTTP errors are directly related to the quality of your OpenAPI documentation. When your error handling is consistent — always returning { statusCode, error, message } for 4xx errors — your OpenAPI spec can accurately document the response schema for each status code. @hapi/boom is the easiest to document in OpenAPI because its output.payload structure is fixed: any team or tool that generates OpenAPI specs from runtime behavior can reliably infer the error shape without special-casing individual endpoints.
http-errors is more challenging to document precisely because the response shape is whatever your error-handling middleware produces. Teams typically define a standard error response type in TypeScript — interface ApiError { statusCode: number; message: string; error?: string } — and ensure the error handler serializes http-errors to that shape. The TypeScript type serves as the documentation contract: OpenAPI generators that read your route handler return types can infer the error response from the declared type rather than from the runtime error object.
http-status-codes is uniquely useful in the context of OpenAPI generation tools. Libraries like tsoa and @nestjs/swagger generate OpenAPI specs from TypeScript decorators and return types. When your handler declares @Response<ApiError>(StatusCodes.NOT_FOUND, "Package not found"), the generator uses the StatusCodes.NOT_FOUND constant (404) to populate the OpenAPI response entry, and the ApiError type to generate the response schema. This explicit documentation of error cases — rather than relying on runtime error-handling middleware — produces more accurate specs and better developer documentation for API consumers.
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on http-errors v2.x, @hapi/boom v10.x, and http-status-codes v2.x.
Compare HTTP error handling and API tooling on PkgPulse →
See also: h3 vs polka vs koa 2026 and proxy-agent vs global-agent vs hpagent, better-sqlite3 vs libsql vs sql.js.