Error Handling in JavaScript
Learn how to handle errors in JavaScript — try/catch/finally, built-in error types, custom error classes, rethrowing, and async error patterns.
Good error handling is the difference between an app that crashes silently and one that tells you exactly what went wrong. JavaScript gives you several layers to work with — synchronous try/catch, async .catch(), and global handlers for anything that slips through. The goal is to catch errors at the level where you can do something useful with them, and propagate everything else upward.
try / catch / finally
try/catch is the foundational mechanism for handling errors in synchronous code. The catch block only runs if an exception is thrown inside the try block; the finally block runs regardless — making it the right place for cleanup code that must execute whether or not an error occurred.
function parseJSON(raw) {
try {
return JSON.parse(raw);
} catch (err) {
// JSON.parse throws a SyntaxError on invalid input
console.error("Invalid JSON:", err.message);
return null; // return a safe fallback instead of crashing
}
}
parseJSON('{"name":"Alice"}'); // { name: "Alice" }
parseJSON("not json"); // logs error, returns null
finally runs regardless of whether an error was thrown — perfect for releasing resources like file handles or database connections:
function readConfig(path) {
let file;
try {
file = openFile(path); // hypothetical synchronous API
return JSON.parse(file.read());
} catch (err) {
// Add context before rethrowing — the original error alone may not say which file failed
throw new Error(`Config error at ${path}: ${err.message}`);
} finally {
file?.close(); // always close the file — even if an error was thrown
}
}
Built-in Error Types
JavaScript ships with several specialised error types, all extending Error. Knowing which type to expect helps you write more precise catch blocks — for example, a SyntaxError from JSON.parse should be handled differently from a TypeError caused by a null dereference.
// TypeError — wrong type or null/undefined access
try {
null.toString();
} catch (e) {
console.log(e instanceof TypeError); // true
console.log(e.name); // "TypeError"
console.log(e.message); // "Cannot read properties of null..."
}
// RangeError — value out of allowed range
try {
new Array(-1);
} catch (e) {
console.log(e instanceof RangeError); // true
}
// ReferenceError — accessing a variable that doesn't exist
try {
console.log(undeclaredVariable);
} catch (e) {
console.log(e instanceof ReferenceError); // true
}
// SyntaxError — only throwable at runtime from eval() or JSON.parse()
try {
JSON.parse("{bad json}");
} catch (e) {
console.log(e instanceof SyntaxError); // true
}
// URIError — malformed argument to encodeURI/decodeURI
try {
decodeURIComponent("%");
} catch (e) {
console.log(e instanceof URIError); // true
}
Checking error type in a catch block
Using instanceof lets you handle different error types differently in the same catch block, rather than treating all errors identically:
function handleError(err) {
if (err instanceof TypeError) {
console.error("Type problem:", err.message);
} else if (err instanceof RangeError) {
console.error("Out of range:", err.message);
} else {
// Unknown — rethrow so it isn't silently swallowed
throw err;
}
}
Custom Error Classes
Extending Error lets you create domain-specific errors that carry structured context — status codes, field names, error codes — rather than just a message string. This makes error handling at API boundaries much cleaner, and lets you instanceof check for specific error types rather than parsing message strings.
class AppError extends Error {
constructor(message, code, statusCode = 500) {
super(message);
this.name = "AppError";
this.code = code;
this.statusCode = statusCode;
// Maintain a proper stack trace in V8 (Node.js / Chrome)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, AppError);
}
}
}
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", 400);
this.name = "ValidationError";
this.field = field;
}
}
// Usage — throw rich errors with structured context
function getUser(id) {
const user = db.find(id);
if (!user) throw new NotFoundError("User", id);
return user;
}
function validateEmail(email) {
if (!email.includes("@")) {
throw new ValidationError("email", "Email must contain @");
}
}
Handling custom errors
Custom error classes pay off at the boundary where errors become HTTP responses or UI messages — you can branch cleanly on error type rather than matching against fragile message strings:
function handleRequest(id, email) {
try {
validateEmail(email);
const user = getUser(id);
return { ok: true, user };
} catch (err) {
if (err instanceof ValidationError) {
return { ok: false, status: 400, field: err.field, message: err.message };
}
if (err instanceof NotFoundError) {
return { ok: false, status: 404, message: err.message };
}
// Unexpected error — rethrow to avoid swallowing bugs
throw err;
}
}
Rethrowing Errors
Only catch what you can meaningfully handle. Rethrowing preserves the original stack trace and ensures unexpected errors propagate to a handler that can deal with them, rather than disappearing silently into an empty catch block.
async function loadUserProfile(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new AppError(`HTTP ${response.status}`, "HTTP_ERROR", response.status);
}
return await response.json();
} catch (err) {
// Translate network errors into a domain error with a consistent shape
if (err instanceof TypeError && err.message.includes("fetch")) {
throw new AppError("Network unavailable", "NETWORK_ERROR", 503);
}
// For everything else, rethrow as-is — don't hide unexpected failures
throw err;
}
}
Async Error Handling
Async errors follow the same principles as synchronous ones, but the mechanics differ depending on whether you’re using async/await or Promise chains. The key rule: always attach a rejection handler, either via try/catch around await, or via .catch() on the Promise.
try / catch with async / await
async function fetchData(url) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
return await res.json();
} catch (err) {
console.error("fetchData failed:", err.message);
return null; // return a safe fallback
}
}
.catch() on Promises
fetch("/api/data")
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => console.log(data))
.catch(err => console.error("Request failed:", err.message));
Handling multiple async operations
async function loadDashboard(userId) {
// Promise.all — run in parallel, fail fast if any operation fails
const [profile, posts, settings] = await Promise.all([
fetchProfile(userId),
fetchPosts(userId),
fetchSettings(userId),
]);
return { profile, posts, settings };
}
// Promise.allSettled — run in parallel, allow partial success
async function loadDashboardSafe(userId) {
const results = await Promise.allSettled([
fetchProfile(userId),
fetchPosts(userId),
fetchSettings(userId),
]);
const [profile, posts, settings] = results.map(r =>
r.status === "fulfilled" ? r.value : null // null signals a failed widget
);
return { profile, posts, settings };
}
Async error utility
This attempt wrapper trades try/catch boilerplate for a Go-style [error, value] tuple, which some developers find easier to scan in functions that call many async operations:
// Wraps a promise and always resolves to [error, data]
async function attempt(promise) {
try {
const data = await promise;
return [null, data];
} catch (err) {
return [err, null];
}
}
// Usage — no nested try/catch needed
const [err, user] = await attempt(fetchUser(id));
if (err) {
console.error("Could not load user:", err.message);
} else {
console.log(user.name);
}
Global Error Handlers
Global handlers catch errors that slip through all other handlers — unhandled rejections, uncaught exceptions, and errors in third-party code. In production, these should send to an error tracking service (Sentry, Datadog, etc.) rather than just logging.
// Browser — catch uncaught synchronous errors
window.addEventListener("error", event => {
console.error("Uncaught error:", event.error);
// send to error tracking service
});
// Browser — catch unhandled Promise rejections
window.addEventListener("unhandledrejection", event => {
console.error("Unhandled promise rejection:", event.reason);
event.preventDefault(); // suppress the default browser console warning
});
// Node.js
process.on("uncaughtException", err => {
console.error("Uncaught exception:", err);
process.exit(1); // mandatory — application state may be corrupt
});
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled rejection at:", promise, "reason:", reason);
});
Real-World: Service Layer Error Handling
A service layer sits between your HTTP routes and the database. It’s the right place to validate inputs, wrap database errors, and throw typed errors that route handlers can translate into HTTP responses.
class UserService {
async createUser({ email, password, name }) {
// Validate inputs — throw before touching the database
if (!email?.includes("@")) throw new ValidationError("email", "Invalid email");
if (!password || password.length < 8) {
throw new ValidationError("password", "Password must be at least 8 characters");
}
// Wrap database calls — translate infrastructure errors into domain errors
let existing;
try {
existing = await db.users.findByEmail(email);
} catch (err) {
throw new AppError("Database unavailable", "DB_ERROR", 503);
}
if (existing) throw new AppError("Email already registered", "DUPLICATE_EMAIL", 409);
try {
const hash = await bcrypt.hash(password, 12);
return await db.users.create({ email, password: hash, name });
} catch (err) {
throw new AppError(`Failed to create user: ${err.message}`, "CREATE_FAILED", 500);
}
}
}
Common Pitfalls
Empty catch blocks destroy debugging context:
// Never do this — errors disappear without a trace
try {
riskyOperation();
} catch (e) {} // silent failure
// At minimum, log and rethrow
try {
riskyOperation();
} catch (e) {
console.error(e);
throw e; // or handle meaningfully
}
finally runs even when you return inside try:
function test() {
try {
return "from try";
} finally {
return "from finally"; // overrides the try return — surprising!
}
}
console.log(test()); // "from finally"
// Avoid returning from finally — use it only for cleanup
Forgetting await inside try/catch:
// Bug — the catch never fires because the Promise is not awaited
async function buggy() {
try {
const data = fetchData(); // missing await — returns a Promise, doesn't throw here
} catch (err) {
console.error(err); // never reached
}
}
// Fix — await the Promise so rejections become catchable exceptions
async function fixed() {
try {
const data = await fetchData();
} catch (err) {
console.error(err);
}
}