Bad error handling is invisible until it costs you. A swallowed exception that corrupts data silently. A failed API call that returns undefined and causes a crash three function calls later. A try/catch that catches an error, logs nothing useful, and lets the user stare at a blank screen.
Good error handling is a system — not a reflex to wrap things in try/catch. This guide builds that system from the ground up.
JavaScript's Built-In Error Types
JavaScript ships with seven error constructors. Each represents a specific category of failure:
Error — the base class. Use it for application-level errors that don't fit a more specific type.
TypeError — wrong type. Accessing a property on null or undefined, calling a non-function, passing the wrong type to a function that requires a specific one.
ReferenceError — variable doesn't exist. Accessing an undeclared variable, using a let/const in its TDZ.
SyntaxError — invalid JavaScript. Thrown by the parser — you can't catch it in the file that contains it (the file never executes). Can be caught from eval() or JSON.parse().
RangeError — value out of range. new Array(-1), Number.toFixed(200), infinite recursion.
URIError — malformed URI in decodeURIComponent().
EvalError — legacy, rarely thrown in modern JS.
null.property // TypeError: Cannot read properties of null
undeclaredVar // ReferenceError: undeclaredVar is not defined
JSON.parse('{bad}') // SyntaxError: Unexpected token b in JSON
new Array(-1) // RangeError: Invalid array length
Every error object has:
message— human-readable description
name— the error class name ('TypeError','RangeError', etc.)
stack— stack trace string (engine-specific format)
Custom Error Classes — The Right Way
Throwing a plain Error with a message is a start. But in a real application you need to handle errors differently based on their type — retry on network failure, redirect on auth failure, show a specific message on validation failure. Custom error classes make this possible:
class AppError extends Error {
constructor(message, options = {}) {
super(message)
this.name = this.constructor.name // 'NetworkError', 'ValidationError', etc.
this.code = options.code // machine-readable error code
this.context = options.context // relevant data for debugging
this.isOperational = options.isOperational ?? true // expected vs programmer error
// Maintains proper stack trace in V8 (Node.js, Chrome) if (Error.captureStackTrace) { Error.captureStackTrace(this, this.constructor) } } }
class NetworkError extends AppError { constructor(message, statusCode, options = {}) { super(message, { code: HTTP_${statusCode}, ...options }) this.statusCode = statusCode } }
class ValidationError extends AppError { constructor(message, field, options = {}) { super(message, { code: 'VALIDATION_FAILED', ...options }) this.field = field } }
class AuthError extends AppError { constructor(message, options = {}) { super(message, { code: 'UNAUTHORIZED', ...options }) this.statusCode = 401 } }
Now error handling becomes decision-making instead of blind catching:
async function loadUserProfile(userId) {
try {
const user = await fetchUser(userId)
return user
} catch (err) {
if (err instanceof AuthError) {
redirect('/login')
} else if (err instanceof NetworkError && err.statusCode >= 500) {
scheduleRetry()
} else if (err instanceof ValidationError) {
showFieldError(err.field, err.message)
} else {
// Unexpected error — surface it, don't swallow it
errorReporter.capture(err)
throw err
}
}
}
try/catch — What It Does and Doesn't Catch
try/catch catches synchronous errors and rejected Promises when used with await:
// ✓ Catches synchronous errors
try {
const data = JSON.parse(invalidJson)
} catch (err) {
console.error('Parse failed:', err.message)
}
// ✓ Catches async errors with await try { const user = await fetchUser(id) } catch (err) { console.error('Fetch failed:', err.message) }
// ❌ Does NOT catch asynchronous errors without await try { setTimeout(() => { throw new Error('this escapes try/catch') }, 100) } catch (err) { // Never runs — the throw happens after try/catch has already finished }
// ❌ Does NOT catch unhandled Promise rejections try { fetchUser(id) // no await — rejection is unhandled } catch (err) { // Never runs }
The finally block: runs whether the try block succeeded or threw. Critical for cleanup — closing connections, hiding loading states, releasing locks:
async function withTransaction(db, operations) {
const transaction = await db.beginTransaction()
try {
const result = await operations(transaction)
await transaction.commit()
return result
} catch (err) {
await transaction.rollback()
throw err // re-throw — caller needs to know it failed
} finally {
transaction.release() // always release the connection
}
}
The catch variable is scoped to the catch block — you can't use it after:
try {
riskyOperation()
} catch (err) {
logged = true
}
console.log(err) // ReferenceError — err doesn't exist here
The Two Biggest Mistakes
Mistake 1: Swallowing errors
// ❌ The worst pattern in production code
try {
await doSomething()
} catch (err) {
// Completely silent — the error happened, nobody will ever know
}
// ❌ Almost as bad — logs but doesn't re-throw or handle try { await doSomething() } catch (err) { console.log(err) // logs and moves on as if nothing happened }
If you catch an error and don't handle it meaningfully, re-throw it. Never let errors disappear silently.
Mistake 2: Catching errors you can't handle
Catch errors at the level where you can do something about them. Don't wrap every function in try/catch "to be safe" — that just spreads the handling logic everywhere and makes the actual problem invisible.
// ❌ Wrapping everything — error is lost at the lowest level
async function getUserName(id) {
try {
const user = await fetchUser(id)
return user.name
} catch (err) {
return null // caller has no idea why name is null
}
}
// ✓ Let errors propagate to where they can be meaningfully handled async function getUserName(id) { const user = await fetchUser(id) // let this throw if it fails return user.name }
// Handle at the component or route level where you control the UI async function UserPage({ id }) { try { const name = await getUserName(id) return render(name) } catch (err) { if (err instanceof NetworkError) return renderError('Failed to load user') throw err // unexpected — escalate } }
Async Error Handling Patterns
Promise chains:
fetchUser(id)
.then(user => processUser(user))
.then(result => saveResult(result))
.catch(err => {
// Catches ANY rejection from any step in the chain above
handleError(err)
})
async/await with error boundaries:
// Wrap risky operations together when a single error should abort the whole thing
async function processOrder(orderId) {
try {
const order = await fetchOrder(orderId)
const payment = await chargePayment(order.total)
const receipt = await sendReceipt(order, payment)
return { order, payment, receipt }
} catch (err) {
await cancelOrder(orderId)
throw new OrderError(Order ${orderId} failed: ${err.message}, { cause: err })
}
}
// Independent operations — handle failures separately async function loadDashboard(userId) { const [analyticsResult, notificationsResult] = await Promise.allSettled([ fetchAnalytics(userId), fetchNotifications(userId), ])
return { analytics: analyticsResult.status === 'fulfilled' ? analyticsResult.value : null, notifications: notificationsResult.status === 'fulfilled' ? notificationsResult.value : [], } }
Error cause (ES2022) — chain errors with context:
try {
await db.query(sql)
} catch (err) {
throw new DatabaseError('Query failed', {
cause: err, // original error preserved
context: { sql }
})
}
// Access the original error: try { await runOperation() } catch (err) { console.log(err.cause) // the original underlying error }
Global Error Handling — The Safety Net
Unhandled errors and rejections that escape local catch blocks need a global handler. Never let them disappear silently:
// Browser — unhandled synchronous errors
window.addEventListener('error', (event) => {
errorReporter.capture({
message: event.message,
source: event.filename,
line: event.lineno,
error: event.error,
})
// Return true to prevent the default browser error console
})
// Browser — unhandled Promise rejections window.addEventListener('unhandledrejection', (event) => { errorReporter.capture(event.reason) event.preventDefault() // prevents console warning })
// Node.js process.on('uncaughtException', (err) => { logger.fatal({ err }, 'Uncaught exception — shutting down') process.exit(1) // always exit — process is in unknown state })
process.on('unhandledRejection', (reason, promise) => { logger.error({ reason }, 'Unhandled rejection') // In Node 15+, this also causes process exit })
Designing for Observability
A caught error is only valuable if you can understand what happened from the outside. Every error you handle should leave a trace:
class ErrorReporter {
static capture(err, context = {}) {
const report = {
message: err.message,
name: err.name,
stack: err.stack,
code: err.code,
context: { ...err.context, ...context },
timestamp: new Date().toISOString(),
userId: getCurrentUserId(),
sessionId: getSessionId(),
}
// In development: console. In production: Sentry, Datadog, etc. if (process.env.NODE_ENV === 'production') { Sentry.captureException(err, { extra: report }) } else { console.group(🚨 ${err.name}: ${err.message}) console.error(report) console.groupEnd() } } }
The goal: when an error alert wakes you up at 2am, the logged error should contain everything you need to reproduce and fix it without asking the user anything.
The Error Handling Checklist
- Custom error classes for each category of failure your app produces
instanceofto route different errors to different handlers
- Never swallow errors — log or re-throw
finallyfor cleanup that must run regardless of outcome
Promise.allSettledwhen partial failure is acceptable
- Global handlers for everything that slips through
- Error cause (
{ cause: err }) for chained errors
- Context attached to errors at the throw site — not reconstructed at the catch site
- Operational errors (expected failures) vs programmer errors (bugs) handled differently