Robust error handling is what separates production code from toy projects. Master try/catch, custom errors, and async error patterns.
Picture an airline's safety system. When something goes wrong mid-flight, there's a strict escalation protocol. The co-pilot handles it first. If they can't, it escalates to the captain. If neither can, the plane's automated systems take over. At each level, the person (or system) either fixes the problem or passes it up. Critically, nobody silently ignores the problem — they either handle it or explicitly pass it on. Error handling in JavaScript follows the same escalation logic. When an error is thrown, JavaScript looks for the nearest catch block in the current call stack. If it finds one, that block handles the error. If not, it propagates up to the next function in the stack. If no catch block is found anywhere, the error becomes an uncaught exception and crashes the program (or triggers the global unhandledRejection event). The key insight: error handling is a design decision, not an afterthought. The question is never just "where do I put try/catch?" — it's "at which layer should this error be handled, transformed, or re-thrown?" A database connection error means something different to your data layer, your service layer, and your API response layer. Good error handling routes errors to the right level, transforms them appropriately at each boundary, and never silently swallows them.
// Error() constructor — base type
const err = new Error('Something went wrong')
err.message // 'Something went wrong'
err.name // 'Error'
err.stack // 'Error: Something went wrong\n at ...' — call stack at creation
// Built-in error subtypes — each signals a specific category of problem:
new TypeError('Expected string, got number') // wrong type
new RangeError('Value must be between 0 and 1') // value out of range
new ReferenceError('myVar is not defined') // bad variable reference
new SyntaxError('Unexpected token }') // invalid syntax
new URIError('URI malformed') // bad URI encoding
new EvalError('...') // eval-related (rare)
// All inherit from Error — share message, name, stack
const te = new TypeError('bad type')
te instanceof TypeError // true
te instanceof Error // true — prototype chain
te.name // 'TypeError'
// Extend Error to add domain-specific context
class AppError extends Error {
constructor(message, code, statusCode = 500) {
super(message)
this.name = 'AppError'
this.code = code // machine-readable error code
this.statusCode = statusCode // HTTP status
// Fix the prototype chain in TypeScript / transpiled environments:
Object.setPrototypeOf(this, new.target.prototype)
}
}
class NotFoundError extends AppError {
constructor(resource, id) {
super(`${resource} with id ${id} not found`, 'NOT_FOUND', 404)
this.name = 'NotFoundError'
this.resource = resource
this.id = id
}
}
class ValidationError extends AppError {
constructor(field, message) {
super(message, 'VALIDATION_ERROR', 422)
this.name = 'ValidationError'
this.field = field
}
}
// Usage:
throw new NotFoundError('User', 123)
throw new ValidationError('email', 'Must be a valid email address')
// Catching and discriminating:
try {
await getUser(id)
} catch (err) {
if (err instanceof NotFoundError) {
return res.status(404).json({ error: err.message })
}
if (err instanceof ValidationError) {
return res.status(422).json({ field: err.field, error: err.message })
}
throw err // re-throw unknown errors — don't swallow them
}
// Basic structure:
try {
const data = JSON.parse(userInput) // throws SyntaxError if invalid
process(data)
} catch (err) {
console.error('Parsing failed:', err.message)
// err is scoped to the catch block — not accessible outside
} finally {
cleanup() // ALWAYS runs — success, catch, or even if catch re-throws
}
// finally runs even if try has a return:
function example() {
try {
return 1
} finally {
console.log('finally runs') // logs before the function returns
// If finally also returns, it OVERRIDES the try return:
// return 2 ← would return 2, silently discarding return 1
}
}
// Catch without a binding — ES2019
try {
mightFail()
} catch { // no (err) needed if you don't use the error
useFallback()
}
// Re-throwing — only handle what you know about:
try {
connectToDatabase()
} catch (err) {
if (err.code === 'ECONNREFUSED') {
throw new AppError('Database unavailable', 'DB_DOWN', 503)
}
throw err // don't know this error — let it propagate
}
// Pattern 1: async/await with try/catch
async function loadUser(id) {
try {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) {
throw new NotFoundError('User', id) // HTTP error is not a rejection
}
return await res.json()
} catch (err) {
if (err instanceof NotFoundError) throw err // re-throw known errors
throw new AppError('Failed to load user', 'FETCH_ERROR')
}
}
// Pattern 2: Promise .catch()
fetchUser(id)
.then(processUser)
.catch(err => {
if (err instanceof NotFoundError) showNotFound()
else reportError(err)
})
// Pattern 3: Go-style result tuple — clean inline error handling
async function safeCall(promise) {
try { return [null, await promise] }
catch (err) { return [err, null] }
}
const [err, user] = await safeCall(fetchUser(id))
if (err) return handleError(err)
// user is safe to use here
// Pattern 4: Handling per-operation in a sequence
async function processOrder(orderId) {
const [userErr, user] = await safeCall(fetchUser(orderId.userId))
if (userErr) throw new AppError('Could not fetch user', 'USER_FETCH_FAILED')
const [invErr, inventory] = await safeCall(checkInventory(orderId))
if (invErr) throw new AppError('Inventory check failed', 'INVENTORY_ERROR')
return processPayment(user, inventory)
}
// Browser — catch all unhandled Promise rejections
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled Promise rejection:', event.reason)
event.preventDefault() // prevent console error in some browsers
reportToErrorService(event.reason)
})
// Browser — catch all uncaught synchronous errors
window.addEventListener('error', (event) => {
console.error('Uncaught error:', event.error)
reportToErrorService(event.error)
})
// Node.js equivalents:
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled rejection:', reason)
process.exit(1) // recommended — don't continue with unknown state
})
process.on('uncaughtException', (err) => {
console.error('Uncaught exception:', err)
// Perform cleanup then exit — process state is undefined after uncaught exception
gracefulShutdown().finally(() => process.exit(1))
})
// Class component — only way to catch render errors
class ErrorBoundary extends React.Component {
state = { hasError: false, error: null }
static getDerivedStateFromError(error) {
return { hasError: true, error }
}
componentDidCatch(error, info) {
reportToErrorService(error, info.componentStack)
}
render() {
if (this.state.hasError) {
return
}
return this.props.children
}
}
// Usage:
// Note: Error boundaries do NOT catch:
// - Event handlers (use regular try/catch)
// - Async code (use try/catch or .catch())
// - The error boundary component itself
// - Server-side rendering errors
// ❌ Swallowing errors — silent failures are the hardest bugs to find
try {
await syncData()
} catch (err) {
// nothing — error disappears
}
// ❌ Catching too broadly — hiding bugs
try {
doEverything() // 50 lines of code
} catch (err) {
console.log('something failed') // which thing? who knows
}
// ❌ Using errors for flow control — errors are expensive to create (stack traces)
function findUser(id) {
try {
return users[id] || throw new Error()
} catch {
return null // just use: return users[id] ?? null
}
}
// ❌ Losing the original error on re-throw
try {
connectDB()
} catch (err) {
throw new Error('DB failed') // original error lost — stack trace gone
}
// ✓ Preserve original as cause (ES2022):
throw new Error('DB failed', { cause: err })
// err.cause is now the original — full chain preservedMany devs think try/catch catches all errors including async ones — but actually try/catch only catches synchronous errors and errors from awaited Promises in async functions. A Promise rejection without await, a setTimeout callback that throws, or an event listener callback that throws will NOT be caught by a surrounding try/catch. Async errors need their own try/catch inside the async context, or a global unhandledRejection handler.
Many devs think the finally block only runs when no error is thrown — but actually finally always runs, regardless of whether the try block succeeded, the catch block ran, or the catch block re-threw. Even if try or catch contains a return statement, finally runs before the function actually returns. The only exception is process.exit() or a host environment crash. This makes finally reliable for cleanup that must always happen.
Many devs think instanceof checks on errors are always reliable for custom error classes — but actually when custom error classes are transpiled by Babel or TypeScript to ES5 (for old browser support), the prototype chain breaks. A transpiled class extending Error may fail instanceof checks. The fix is adding Object.setPrototypeOf(this, new.target.prototype) in the constructor, which explicitly repairs the chain in transpiled environments.
Many devs think empty catch blocks are an acceptable "I'll handle it later" pattern — but actually swallowed errors are one of the most dangerous patterns in production code. A silent catch means the system continues in an unknown state, producing cascading failures far from the original error. Always at minimum log caught errors, even if you're not re-throwing. console.error(err) in a catch block takes one second and saves hours of debugging.
Many devs think the Error constructor should be called with an error code string instead of a message — but actually the Error constructor takes a human-readable message. Machine-readable codes belong in a custom property (err.code). The Error message is for human debugging, the code is for programmatic handling. Mixing them (throwing new Error('NOT_FOUND') and parsing the string) is fragile — add a code property to custom error classes instead.
Many devs think Promise rejection with a string is equivalent to rejecting with an Error — but actually reject('something went wrong') creates a rejection with a string as the reason, which has no stack trace and no type information. Always reject with an Error object: reject(new Error('message')). This gives you a stack trace pointing to where the rejection originated, which is the most valuable debugging information.
Express.js error handling middleware is a four-argument function (err, req, res, next) — the extra err argument signals it's an error handler. Every thrown error or next(err) call in any route handler eventually reaches this middleware. The pattern of building custom AppError classes with HTTP status codes and feeding them to next() — rather than writing res.status(404).json() in every route — is how production Express applications centralise error handling and keep routes clean.
Sentry and Datadog's APM products work by replacing the global window.onerror and unhandledrejection handlers, and monkeypatching setTimeout, addEventListener, and Promise to wrap callbacks in try/catch. Every error in your application flows through their handlers, gets enriched with user context and stack traces, and is shipped to their servers. Understanding where errors propagate in JavaScript is exactly what makes these tools possible and what makes configuring them correctly non-trivial.
React Query (TanStack Query) uses a layered error handling model — each query can have an onError callback, error boundaries can catch render errors caused by thrown query results, and a global QueryClient can have a default error handler. The library deliberately re-throws errors in the render phase (when throwOnError: true) so React's error boundary system can catch them. This is the "errors as values in render" pattern made practical.
Database transaction handling in Node.js depends critically on try/catch/finally — the pattern is begin transaction, try your operations, commit in try, rollback in catch, release the connection in finally. Putting the connection release in finally guarantees it happens whether the transaction succeeded or failed, preventing connection pool exhaustion. Missing the finally and handling release in try and catch separately always has at least one bug.
Next.js's error.tsx file is an error boundary for route segments — any unhandled error thrown during rendering a route segment is caught and rendered as error.tsx instead. This is built on React's error boundary mechanism and means that Next.js application errors are isolated to segments — a failing /dashboard doesn't take down /profile. The reset() function provided to error.tsx attempts to re-render the segment by clearing the error state.
The Error cause chain (ES2022) — new Error('High-level message', { cause: originalError }) — is how production error handling should chain errors across system boundaries. Your database layer throws a low-level pg error. Your data layer catches it and throws a DatabaseError with the pg error as cause. Your API layer catches that and returns an appropriate HTTP response, logging the full cause chain. Without cause chaining, the original context is lost, and debugging means guessing which database call failed.
How do you handle errors in async/await properly?
Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.