JavaScript Error Handling: try/catch/finally, Custom Errors, Rejected Promises and Retries

Key takeaways

An unawaited promise escapes a plain try/catch, and a return inside finally can override the one in try. The post covers those gotchas, why instanceof checks can mislead, custom error hierarchies, and ends with an API client that retries, wraps results and logs failures.

Introduction

Error handling deals with exceptional conditions that occur while your program runs — a network request that times out, a user who submits a malformed payload, a third-party library that throws on an edge case you didn’t anticipate. JavaScript gives you exactly one built-in control-flow mechanism for this: throw/try/catch. That single mechanism, however, behaves very differently depending on where the failure originates.

A synchronous throw inside a try block interrupts execution immediately and hands control to the matching catch on the same call stack. A promise that rejects, or an async function that throws, does not do this — the rejection travels through the microtask queue instead of the call stack, which means a plain try/catch wrapped around code that merely starts an async operation will not see the failure at all. This distinction is the single most common source of “silent failure” bugs in production JavaScript: requests that fail without any log line, batch jobs that skip items without an error, and dashboards that show stale data with no indication anything went wrong.

This article works through the full toolbox — try/catch/finally semantics and their surprising edge cases, the built-in Error hierarchy, how to design custom error classes that survive real-world constraints like bundling and cross-realm boundaries, the mechanics of asynchronous error propagation, the global “last resort” handlers browsers and Node.js expose, and the production patterns (result wrappers, retries, structured logging) that turn ad-hoc catch blocks into a coherent error-handling strategy.


try-catch-finally

Basics

try {
    const result = riskyOperation();
    console.log(result);
} catch (error) {
    console.error("Error occurred:", error.message);
} finally {
    console.log("Cleanup");
}

The three clauses run in a strict, well-defined order regardless of how the try block exits. First, the try block runs until it either completes, throws, or hits a return. If it throws, execution jumps directly to catch — no remaining statements in try run, even ones that look unrelated to the failure. If it completes normally (or the catch block itself finishes handling the error), control moves to finally. Critically, finally runs every single time — on the success path, on the caught-error path, and even when catch re-throws or the function returns from inside try. This makes finally the correct place for cleanup that must happen unconditionally: closing a file handle, releasing a lock, clearing a loading spinner, or ending a database transaction — logic that should not be duplicated in both the success and failure branches.

Practical example

function divide(a, b) {
    if (b === 0) {
        throw new Error("Cannot divide by zero");
    }
    return a / b;
}
try {
    console.log(divide(10, 2));  // 5
    console.log(divide(10, 0));  // Error!
    console.log("This line never runs");
} catch (error) {
    console.error("Error:", error.message);
} finally {
    console.log("Calculation done");
}

Notice the comment on the third console.log call: once divide(10, 0) throws, execution never reaches "This line never runs". This is worth internalizing precisely because it is easy to assume a try block behaves like a sequence of independent statements where only the failing line is skipped — it does not. The moment any statement throws, the entire remainder of the try block is abandoned, which is exactly why you should keep try blocks narrowly scoped around the operation that can actually fail, rather than wrapping ten unrelated lines in one block and hoping the catch can tell which one blew up.

The finally-overrides-return gotcha

function getStatus() {
    try {
        return "success";
    } finally {
        // This looks like harmless cleanup logging, but it silently
        // discards the "success" return value above.
        return "overridden";
    }
}
console.log(getStatus());  // "overridden" — not "success"!

function riskyRead() {
    try {
        throw new Error("read failed");
    } finally {
        // An unconditional return in `finally` also swallows the
        // exception — the caller never even sees the error.
        return "fallback value";
    }
}
console.log(riskyRead());  // "fallback value" — the thrown Error vanished

This is one of the sharpest edges in the language, and it catches experienced developers as often as beginners. If a finally block contains its own return (or throw, or break/continue that exits the block), it completely overrides whatever the try or catch block was about to do — including an in-flight exception. The exception is not logged, not re-thrown, not accessible anywhere; it is simply discarded as if throw had never happened. The practical rule is straightforward: never put a return statement inside finally unless you are deliberately overriding the result, and treat that pattern as a code-review red flag when you see it in someone else’s diff. finally should perform side effects (closing resources, logging, releasing locks), not produce or replace values.

Nested try-catch

try {
    try {
        throw new Error("inner error");
    } catch (innerError) {
        console.log("Handled inner:", innerError.message);
        throw new Error("outer error");
    }
} catch (outerError) {
    console.log("Handled outer:", outerError.message);
}

Nesting is useful when you need to attempt a fallback operation after the first one fails, but re-throwing from the inner catch (as shown above) is what actually propagates the failure to the outer handler — without that throw, the outer catch would never run because, as far as the outer try block is concerned, the inner try/catch already fully handled the problem and returned normally.


The Error object

Built-in error types

// Error: generic
throw new Error("generic error");
// SyntaxError
try {
    eval("{ invalid json");
} catch (e) {
    console.log(e.name);  // SyntaxError
}
// ReferenceError
try {
    console.log(nonExistent);
} catch (e) {
    console.log(e.name);  // ReferenceError
}
// TypeError
try {
    null.toString();
} catch (e) {
    console.log(e.name);  // TypeError
}
// RangeError
try {
    new Array(-1);
} catch (e) {
    console.log(e.name);  // RangeError
}

These four are not arbitrary variety — each one signals a distinct failure category that the JavaScript engine itself distinguishes internally, which is exactly why error.name (rather than parsing error.message with a regex) is the reliable way to branch on them. TypeError means an operation was performed on a value of the wrong type (calling a method that doesn’t exist, or null.toString() as shown here). RangeError means a value was numerically or structurally out of the allowed bounds. ReferenceError means the code referenced a binding that does not exist in the current scope. SyntaxError is special: it is almost always thrown at parse time, before your code even starts executing — the one exception is when you hand a string to eval() or JSON.parse(), which parses at runtime and can therefore be caught, as shown above.

Error properties

try {
    throw new Error("test error");
} catch (error) {
    console.log(error.name);     // Error
    console.log(error.message);  // test error
    console.log(error.stack);    // stack trace
}

error.stack deserves a closer look because it is simultaneously the most useful and the least standardized property on an Error. The ECMAScript spec does not mandate its exact format, or even its existence — it exists because every major JavaScript engine (V8, SpiderMonkey, JavaScriptCore) independently decided to add it as a convention, and V8’s format (Error: message followed by indented at functionName (file:line:column) frames) is what you’ll see in both Chrome and Node.js. Two practical consequences follow: first, never parse .stack programmatically to extract structured data — use error.name and error.message for that, and treat .stack as opaque text meant for a human or a log aggregator. Second, .stack is captured at the moment new Error() runs, not at the moment it is thrown — if you construct an error object and hold onto it before throwing it later, the stack trace reflects the construction site, which can be a subtle debugging pitfall.

Why instanceof can lie to you

class ValidationError extends Error {}

function isValidationError(err) {
    return err instanceof ValidationError;
}

// Works fine within a single module graph:
try {
    throw new ValidationError("bad input");
} catch (e) {
    console.log(isValidationError(e));  // true
}

// But breaks silently across a "realm" boundary — for example, an error
// thrown inside an <iframe>, a Node vm.Script context, or (very commonly
// in practice) when a dependency is duplicated by your bundler because
// two packages depend on different versions of the same library:
//
//   iframe.contentWindow.ValidationError !== window.ValidationError
//
// The error object is a perfectly valid ValidationError from the OTHER
// realm's perspective, but `instanceof` compares against THIS realm's
// ValidationError.prototype — so the check returns false even though
// the error's name and shape are identical.

instanceof works by walking the prototype chain and checking reference equality against a specific constructor’s .prototype object. That is fine as long as there is exactly one copy of the ValidationError class in memory. In practice there often isn’t: a monorepo where two packages each bundle their own copy of a shared error class, a micro-frontend that loads a dependency twice under different script tags, or code that crosses a realm boundary (iframes, Node’s vm module, some serverless isolate setups) will all produce two structurally identical but referentially distinct classes. An error thrown by one and caught by code checking instanceof against the other silently falls through to the else branch — often landing in a generic “unknown error” handler with no indication of why the specific check failed.

The defensive pattern that avoids this entirely is to stop relying on identity and instead attach a plain, serializable discriminator — typically a code string — that survives being copied, serialized to JSON, or sent across a worker/iframe boundary:

class AppError extends Error {
    constructor(message, code, meta = {}) {
        super(message);
        this.name = "AppError";
        this.code = code;      // e.g. "VALIDATION_ERROR", "NOT_FOUND"
        this.meta = meta;
        // Ensures instanceof still works correctly when this class is
        // transpiled down to ES5 target, where extending built-ins
        // like Error needs an explicit prototype fix-up.
        Object.setPrototypeOf(this, AppError.prototype);
    }
}

function handle(err) {
    // A plain string comparison works identically regardless of which
    // realm, bundle copy, or serialization round-trip produced `err`.
    switch (err.code) {
        case "VALIDATION_ERROR":
            return respondWithStatus(400, err.message);
        case "NOT_FOUND":
            return respondWithStatus(404, err.message);
        default:
            return respondWithStatus(500, "Internal Server Error");
    }
}

Use instanceof when you know the error and the check live in the same module graph and realm (the overwhelmingly common case, and it reads more naturally than a string comparison). Reach for an error code when the error might cross a serialization boundary — logged to a file and re-parsed, sent over postMessage, forwarded through an HTTP error response body — because a code property is just data and survives all of those, whereas class identity does not.


Custom errors

Custom error classes

class ValidationError extends Error {
    constructor(message) {
        super(message);
        this.name = "ValidationError";
    }
}
class NetworkError extends Error {
    constructor(message, statusCode) {
        super(message);
        this.name = "NetworkError";
        this.statusCode = statusCode;
    }
}
function validateAge(age) {
    if (typeof age !== 'number') {
        throw new ValidationError("Age must be a number");
    }
    if (age < 0 || age > 150) {
        throw new ValidationError("Age must be between 0 and 150");
    }
    return true;
}
try {
    validateAge("25");
} catch (error) {
    if (error instanceof ValidationError) {
        console.error("Validation error:", error.message);
    } else {
        console.error("Unknown error:", error);
    }
}

Subclassing Error rather than throwing plain strings or objects buys you three things at once: the instanceof checks shown above, an automatically populated .stack trace (a bare { message: "..." } object gets none), and a single, predictable shape (.name, .message, plus whatever custom fields you add) that every catch block downstream can rely on. Setting this.name explicitly, as both ValidationError and NetworkError do here, matters because without it every custom error would print as generic "Error" in logs and stack traces — this.name is what makes console.error and most logging libraries print ValidationError: Age must be a number instead of just Error: Age must be a number, which is often the only clue you get when triaging a production incident from a log line alone.

Handling multiple error types

class DatabaseError extends Error {
    constructor(message, query) {
        super(message);
        this.name = "DatabaseError";
        this.query = query;
    }
}
function processData(data) {
    try {
        if (!data) {
            throw new ValidationError("No data provided");
        }
        
        if (data.age < 0) {
            throw new ValidationError("Age must be positive");
        }
        
        return data;
    } catch (error) {
        if (error instanceof ValidationError) {
            console.error("Validation error:", error.message);
        } else if (error instanceof DatabaseError) {
            console.error("DB error:", error.message, error.query);
        } else {
            console.error("Unknown error:", error);
        }
        throw error;
    }
}

Asynchronous errors

Promises

// .catch()
fetch("https://api.example.com/data")
    .then(response => response.json())
    .then(data => console.log(data))
    .catch(error => console.error("Error:", error));
// Error mid-chain
Promise.resolve(1)
    .then(x => {
        throw new Error("failed!");
    })
    .then(x => console.log(x))
    .catch(error => console.error(error.message))
    .then(() => console.log("Recovered"));
// Multiple promises
Promise.all([
    fetch("/api/users"),
    fetch("/api/posts")
])
.then(responses => Promise.all(responses.map(r => r.json())))
.then(data => console.log(data))
.catch(error => console.error("At least one failed:", error));

async/await

async function fetchData() {
    try {
        const response = await fetch("https://api.example.com/data");
        
        if (!response.ok) {
            throw new NetworkError(`HTTP ${response.status}`, response.status);
        }
        
        const data = await response.json();
        return data;
    } catch (error) {
        console.error("Error:", error.message);
        return null;
    }
}
async function complexOperation() {
    try {
        const data = await fetchData();
        const result = processData(data);
        return result;
    } catch (error) {
        if (error instanceof NetworkError) {
            console.error("Network error:", error.statusCode);
        } else if (error instanceof ValidationError) {
            console.error("Validation error:", error.message);
        } else {
            console.error("Unknown error:", error);
        }
        throw error;
    }
}

Practical patterns

Pattern 1: Result wrapper

class Result {
    constructor(success, data, error) {
        this.success = success;
        this.data = data;
        this.error = error;
    }
    
    static ok(data) {
        return new Result(true, data, null);
    }
    
    static fail(error) {
        return new Result(false, null, error);
    }
}
async function fetchUserSafe(id) {
    try {
        const response = await fetch(`/api/users/${id}`);
        const user = await response.json();
        return Result.ok(user);
    } catch (error) {
        return Result.fail(error.message);
    }
}
const result = await fetchUserSafe(1);
if (result.success) {
    console.log("Data:", result.data);
} else {
    console.error("Error:", result.error);
}

Pattern 2: Retry with backoff

async function retry(fn, maxRetries = 3, delay = 1000) {
    for (let i = 0; i < maxRetries; i++) {
        try {
            return await fn();
        } catch (error) {
            if (i === maxRetries - 1) {
                throw error;
            }
            console.log(`Retry ${i + 1}/${maxRetries}`);
            await new Promise(resolve => setTimeout(resolve, delay * (i + 1)));
        }
    }
}
retry(() => fetch("https://api.example.com/data"))
    .then(response => response.json())
    .then(data => console.log(data))
    .catch(error => console.error("Final failure:", error));

Pattern 3: Error logging

class ErrorLogger {
    static log(error, context = {}) {
        const errorInfo = {
            name: error.name,
            message: error.message,
            stack: error.stack,
            timestamp: new Date().toISOString(),
            ...context
        };
        
        console.error("Error log:", JSON.stringify(errorInfo, null, 2));
        
        // Send to server
        // fetch('/api/errors', { method: 'POST', body: JSON.stringify(errorInfo) });
    }
}
try {
    throw new Error("test error");
} catch (error) {
    ErrorLogger.log(error, { userId: 123, action: "load data" });
}

Practical example: API client

class ApiClient {
    constructor(baseUrl) {
        this.baseUrl = baseUrl;
    }
    
    async request(endpoint, options = {}) {
        const url = `${this.baseUrl}${endpoint}`;
        
        try {
            const response = await fetch(url, options);
            
            if (!response.ok) {
                throw new NetworkError(
                    `HTTP ${response.status}: ${response.statusText}`,
                    response.status
                );
            }
            
            const data = await response.json();
            return Result.ok(data);
        } catch (error) {
            if (error instanceof NetworkError) {
                console.error("Network error:", error.message);
            } else if (error instanceof SyntaxError) {
                console.error("JSON parse error:", error.message);
            } else {
                console.error("Unknown error:", error);
            }
            return Result.fail(error.message);
        }
    }
    
    async get(endpoint) {
        return this.request(endpoint);
    }
    
    async post(endpoint, body) {
        return this.request(endpoint, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(body)
        });
    }
}
const api = new ApiClient("https://api.example.com");
const result = await api.get("/users/1");
if (result.success) {
    console.log("User:", result.data);
} else {
    console.error("Error:", result.error);
}