http-errors vs @hapi/boom vs http-status-codes: HTTP Error Handling in Node.js (2026)
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
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.