πͺ€ Try/Catch Blocks and Error Objects
Knowing the names of errors is step one; step two is responding to them without your whole app falling over. This lesson turns crashes into controlled recoveries using JavaScript's try/catch/finally statement β and shows you the patterns professionals reach for every day.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Write
try/catch/finallyblocks and explain what each part guarantees - Inspect a caught Error object (
name,message,stack) and branch on its type throwyour own errors and know when to re-throw versus swallow- Handle asynchronous errors correctly with
async/awaitand Promise.catch() - Apply real-world patterns: fallback values, retry with backoff, and resource cleanup
Estimated Time: 35β45 minutes β’ Difficulty: Intermediate
Hands-on: Harden a fragile function, then build a resilient fetch helper with retries.
In This Lesson
The Safety Net
By default, an uncaught error in JavaScript stops the current call stack dead. In the browser it dumps a red message to the console and abandons whatever the user was doing; in Node.js it can crash the entire process. A try/catch block is how you intercept that error, decide what to do, and keep the program alive.
π‘ A useful analogy: Error handling is the safety net under a trapeze act. The performer is skilled and rarely falls β the net isn't there because you expect failure, it's there because failure must be anticipated. Good code plans for the fall.
Notice the shape: the catch block runs only on failure, but the finally block runs every time β success or failure. That guarantee is what makes finally so useful for cleanup.
Anatomy of Try/Catch/Finally
The statement has three parts. Only try plus one of catch/finally is required, but you'll usually see all three together.
try {
// 1. TRY β code that might throw
const user = JSON.parse(rawInput);
render(user);
} catch (error) {
// 2. CATCH β runs only if the try block throws
console.error('Could not load user:', error.message);
showFallbackUI();
} finally {
// 3. FINALLY β always runs, error or not
hideSpinner();
}
| Block | Runs when⦠| Typical use |
|---|---|---|
try | Always (it's the code you're guarding) | The risky operation |
catch | Only if something inside try throws | Log, recover, show a message |
finally | Always β even after a return or re-throw | Close files, release locks, hide loaders |
π‘ Keep the try block small
Wrap only the line that can actually fail. A giant try around ten operations tells you something broke but not what. Narrow blocks pinpoint the culprit and let each failure get its own recovery.
Since ES2019 the binding is even optional β if you don't need the error object, you can write catch {}:
function isValidJson(text) {
try {
JSON.parse(text);
return true;
} catch { // no parameter needed
return false;
}
}
Working With the Error Object
Whatever gets thrown lands in the catch parameter. When it's a proper Error (as it should be), it carries the three properties you met in the previous lesson.
try {
throw new Error('Something bad happened');
} catch (error) {
console.log(error.name); // "Error"
console.log(error.message); // "Something bad happened"
console.log(error.stack); // multi-line trace of how we got here
}
Because every built-in error is a subclass of Error, you can branch on the exact type with instanceof and respond differently to each:
try {
riskyOperation();
} catch (error) {
if (error instanceof TypeError) {
handleTypeError(error);
} else if (error instanceof RangeError) {
handleRangeError(error);
} else {
// Unknown error β don't pretend to handle it
console.error('Unexpected:', error);
throw error; // re-throw so a higher layer can decide
}
}
β οΈ You can throw anything β but throw Errors
JavaScript lets you throw 'a string' or throw 42. Don't. Only Error objects carry a stack trace and play nicely with instanceof. Always throw new Error(...) (or a subclass). When catching, guard for the exception with error instanceof Error ? error.message : String(error) if the source is untrusted.
Throwing and Re-throwing
throw is how you raise an error deliberately β usually to reject invalid input early ("fail fast"). Combined with a custom error class, it makes your intent unmistakable.
class ValidationError extends Error {
constructor(message, field) {
super(message);
this.name = 'ValidationError';
this.field = field;
}
}
function validateForm(form) {
if (!form.name) throw new ValidationError('Name is required', 'name');
if (!form.email?.includes('@')) throw new ValidationError('Valid email required', 'email');
if (form.password.length < 8) throw new ValidationError('Password too short', 'password');
return true;
}
try {
validateForm({ name: 'Ray', email: 'ray@x.com', password: '123' });
} catch (error) {
if (error instanceof ValidationError) {
highlightField(error.field); // we know exactly which field
showMessage(error.message);
} else {
throw error; // anything else is not ours
}
}
π Re-throwing (the "catch, add context, throw again" pattern)
A catch block isn't obligated to stop the error. Catching it to add context and then re-throwing lets each layer contribute information while a single top-level handler decides the final response.
async function fetchUser(id) {
try {
return await api.get(`/users/${id}`);
} catch (error) {
error.userId = id; // enrich with context
error.context = 'fetchUser';
throw error; // let the caller handle it
}
}
Handling Async Errors
This is where most beginners get burned. A try/catch only catches errors thrown synchronously inside it. A plain, un-awaited Promise that rejects will slip right past the block. The fix is async/await, which lets you use ordinary try/catch around asynchronous code.
// β
async/await β reads top-to-bottom, catches rejections too
async function loadProfile(id) {
showSpinner();
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`); // fetch does NOT throw on 404/500
return await res.json();
} catch (error) {
console.error('Profile load failed:', error);
showMessage('Could not load your profile. Please try again.');
return null;
} finally {
hideSpinner(); // runs whether we succeeded or failed
}
}
β οΈ fetch() only rejects on network failure
A 404 or 500 response is still a "successful" fetch as far as the Promise is concerned β res.ok is false, but no error is thrown. You must check res.ok yourself and throw if it's false, as above. Forgetting this is one of the most common real-world bugs.
If you're using raw Promises instead of await, attach a .catch() to the chain β a try/catch around the chain won't work:
fetch('/api/profile')
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(displayProfile)
.catch(error => { // catches rejections from anywhere above
console.error('Failed:', error);
showErrorMessage();
})
.finally(hideSpinner);
Battle-tested Patterns
Three patterns cover the vast majority of real error-handling needs.
1. Fallback values
When a failure just means "use a sensible default," catch it and return the default instead of crashing.
function getConfig(key) {
try {
const cfg = JSON.parse(localStorage.getItem('appConfig') || '{}');
return cfg[key] ?? DEFAULTS[key];
} catch (error) {
console.warn('Config unreadable, using defaults:', error.message);
return DEFAULTS[key];
}
}
2. Retry with exponential backoff
Network calls fail transiently. Retrying a few times β waiting a little longer between attempts β recovers from blips without bothering the user. This is a great use of a while loop that keeps trying until it succeeds or exhausts its attempts.
async function fetchWithRetry(url, maxRetries = 3) {
let attempt = 0;
while (attempt < maxRetries) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json(); // success β leave the loop
} catch (error) {
attempt++;
if (attempt >= maxRetries) throw error; // out of chances β give up
const wait = 1000 * 2 ** (attempt - 1); // 1s, 2s, 4s...
console.warn(`Attempt ${attempt} failed, retrying in ${wait}ms`);
await new Promise(r => setTimeout(r, wait));
}
}
}
3. Guaranteed cleanup with finally
Any resource you open β a spinner, a database connection, a file handle β should be released in finally so it happens even when the body throws or returns early.
async function withConnection(work) {
const conn = await pool.connect();
try {
return await work(conn);
} finally {
conn.release(); // always returned to the pool
}
}
Best Practices: Do & Don't
β Do
- Keep
tryblocks small so failures are easy to locate. - Write specific, actionable messages:
'Auth failed: invalid password format', not'Failed'. - Branch on error type with
instanceofand handle each meaningfully. - Use
finallyfor cleanup that must always happen. - Re-throw errors you can't meaningfully handle so a higher layer can.
β οΈ Don't
- Don't swallow errors silently β an empty
catch {}hides bugs you'll pay for later. - Don't catch errors you can't actually respond to; let them bubble up.
- Don't wrap huge blocks in one
tryβ you lose the "which line?" signal. - Don't forget
res.okafterfetch(). - Don't
throwstrings or numbers β throwErrorobjects.
Hands-on Exercise
ποΈ Part A β Harden a fragile function
Objective: The function below crashes on bad input (non-arrays, empty arrays, non-numbers). Add validation and try/catch so it always returns a number or throws a clear, specific error.
function calculateAverage(numbers) {
const sum = numbers.reduce((acc, val) => acc + val, 0);
return sum / numbers.length;
}
π‘ Hint
Validate first (fail fast): is it an array? is it non-empty? are all elements numbers? Throw a TypeError or RangeError with a message that says exactly what was wrong.
β Sample solution
function calculateAverage(numbers) {
if (!Array.isArray(numbers)) {
throw new TypeError('Expected an array of numbers');
}
if (numbers.length === 0) {
throw new RangeError('Cannot average an empty array');
}
if (!numbers.every(n => typeof n === 'number' && !Number.isNaN(n))) {
throw new TypeError('Every element must be a valid number');
}
const sum = numbers.reduce((acc, val) => acc + val, 0);
return sum / numbers.length;
}
// Caller stays clean:
try {
console.log(calculateAverage([4, 8, 15, 16, 23, 42]));
} catch (error) {
console.error(`${error.name}: ${error.message}`);
}
ποΈ Part B β A resilient loader
Objective: Write async function loadData(url) that fetches JSON, checks res.ok, retries up to 3 times on failure with backoff, always hides a spinner in finally, and returns null (with a logged warning) if every attempt fails. Reuse the fetchWithRetry pattern from this lesson.
β Sample solution
async function loadData(url, maxRetries = 3) {
showSpinner();
try {
let attempt = 0;
while (attempt < maxRetries) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (error) {
attempt++;
if (attempt >= maxRetries) {
console.warn('All attempts failed:', error.message);
return null;
}
await new Promise(r => setTimeout(r, 1000 * 2 ** (attempt - 1)));
}
}
} finally {
hideSpinner();
}
}
π― Quick Quiz
Question 1: Which block is guaranteed to run whether or not an error was thrown?
Question 2: After const res = await fetch(url), why must you check res.ok?
Question 3: You catch an error you don't know how to handle. What's the recommended move?
Summary & Quiz
π Key Takeaways
tryguards risky code,catchruns only on failure, andfinallyalways runs β perfect for cleanup.- Read the caught Error object's
name,message, andstack; branch on type withinstanceof. throwdeliberately to fail fast; re-throw what you can't handle so a higher layer can.- Use
async/awaitwithtry/catchfor async code β and always checkres.okafterfetch(). - Reach for the standard patterns: fallbacks, retry with backoff, and
finally-based cleanup.
π Further Reading
- MDN β try...catch statement
- JavaScript.info β Error handling, "try...catch"
- MDN β Using the Fetch API (and why to check
ok)
π What's Next?
You can now catch and recover from errors β but what about the bugs that don't throw at all? Next we'll pick up the debugging tools and strategies that let you hunt down the silent, logical bugs that try/catch can't reach.
π Nicely handled!
Your apps can now stumble without falling. Let's learn to find the bugs that hide.