How to write code that breaks gracefully instead of crashing spectacularly.
Here's what happens when your JavaScript code hits an error and you haven't handled it:
const user = undefined;
console.log(user.name);
// TypeError: Cannot read properties of undefined (reading 'name')
// 💥 Script stops. Everything below this line NEVER runs.
The entire program crashes. Any code after the error? Gone. UI update below it? Never happens. API call scheduled later in the file? Cancelled. One unhandled error takes down everything.
That's not how production software should work. Users submit empty forms, APIs go down, JSON comes back malformed, variables end up undefined when you expected an object. Errors will happen. The question is: does your code crash, or does it handle the situation gracefully?
This is what try, catch, and finally are for. I learned this lesson the hard way in the ChaiCode Web Dev Cohort 2026 — let me save you the pain.
What Are Errors in JavaScript?
An error is something that goes wrong during code execution. JavaScript stops at that point and throws an Error object — a built-in object that contains information about what went wrong.
Common Runtime Errors
// 1. ReferenceError — using a variable that doesn't exist
console.log(myVariable);
// ReferenceError: myVariable is not defined
// 2. TypeError — using a value the wrong way
const num = 42;
num.toUpperCase();
// TypeError: num.toUpperCase is not a function
// 3. SyntaxError — code that JavaScript can't parse
// (caught before execution, can't be caught with try/catch)
// const x = ; ← SyntaxError
// 4. RangeError — value outside allowed range
const arr = new Array(-1);
// RangeError: Invalid array length
// 5. URIError — malformed URI
decodeURIComponent("%");
// URIError: URI malformed
The Error Object
Every error has three useful properties:
try {
undefinedVariable;
} catch (error) {
console.log(error.name); // "ReferenceError"
console.log(error.message); // "undefinedVariable is not defined"
console.log(error.stack); // Full stack trace with file/line numbers
}
-
name— the type of error (TypeError,ReferenceError, etc.) -
message— a human-readable description of what went wrong -
stack— the full call stack at the time of the error (invaluable for debugging)
Using try and catch Blocks
The try/catch statement lets you attempt risky code and handle any errors that occur — without crashing the program.
Basic Syntax
try {
// Code that might throw an error
} catch (error) {
// Code that runs if an error was thrown
}
Example: Without Error Handling
const data = JSON.parse("this is not valid JSON");
console.log(data);
console.log("This line never runs"); // 💥 Script crashed above
Example: With Error Handling
try {
const data = JSON.parse("this is not valid JSON");
console.log(data);
} catch (error) {
console.log("Parsing failed:", error.message);
}
console.log("This line STILL runs! ✅"); // Program continues
Output:
Parsing failed: Unexpected token 't', "this is no"... is not valid JSON
This line STILL runs! ✅
The error was caught. The program didn't crash. Life goes on.
How It Works
try {
// JavaScript TRIES to run this code
// If everything is fine → catch is SKIPPED
// If an error occurs → execution JUMPS to catch immediately
}
catch (error) {
// Only runs if try threw an error
// The error object contains info about what went wrong
}
// Code here continues running either way
More Realistic Examples
// Safely accessing nested properties
try {
const user = { name: "Pratham", address: null };
const city = user.address.city; // address is null!
} catch (error) {
console.log("Could not read address:", error.message);
// "Could not read address: Cannot read properties of null (reading 'city')"
}
// Safely parsing API responses
try {
const response = '{"name": "Pratham", "age": 22}';
const user = JSON.parse(response);
console.log(`User: ${user.name}, Age: ${user.age}`);
} catch (error) {
console.log("Invalid JSON:", error.message);
}
The finally Block
finally is the third piece. It runs no matter what — whether try succeeded or catch was triggered.
Syntax
try {
// Risky code
} catch (error) {
// Handle the error
} finally {
// This ALWAYS runs — success or failure
}
Example
function readConfig(jsonString) {
console.log("⏳ Attempting to parse config...");
try {
const config = JSON.parse(jsonString);
console.log(`✅ Theme: ${config.theme}, Language: ${config.language}`);
} catch (error) {
console.log(`❌ Failed to parse config: ${error.message}`);
} finally {
console.log("🏁 Config parsing attempt complete.");
}
}
// Success case
readConfig('{"theme": "dark", "language": "en"}');
// ⏳ Attempting to parse config...
// ✅ Theme: dark, Language: en
// 🏁 Config parsing attempt complete.
// Failure case
readConfig("not json");
// ⏳ Attempting to parse config...
// ❌ Failed to parse config: Unexpected token 'o', "not json" is not valid JSON
// 🏁 Config parsing attempt complete.
finally ran in both cases. That's its superpower.
When to Use finally
- Hiding loading spinners — show "Loading..." before the try, hide it in finally
- Closing connections — database or file connections that should close regardless
- Cleaning up resources — removing temporary data, resetting state
- Logging — recording that an operation was attempted, no matter the outcome
async function fetchData(url) {
let isLoading = true;
console.log("Loading...");
try {
const response = await fetch(url);
const data = await response.json();
console.log("Data received:", data.title);
} catch (error) {
console.log("Fetch failed:", error.message);
} finally {
isLoading = false;
console.log("Loading complete. isLoading:", isLoading);
}
}
Try → Catch → Finally Execution Order
┌─────────────────────────────────────────────────────┐
│ │
│ try { ... } │
│ │ │
│ ┌───────┴────────┐ │
│ ↓ ↓ │
│ No error? Error thrown? │
│ ↓ ↓ │
│ Skip catch catch (error) { ... } │
│ │ │ │
│ └───────┬────────┘ │
│ ↓ │
│ finally { ... } │
│ (ALWAYS runs) │
│ ↓ │
│ Code after try/catch/finally │
│ continues normally │
│ │
└─────────────────────────────────────────────────────┘
Three Possible Paths
// Path 1: No error
try {
console.log("A"); // ✅ runs
console.log("B"); // ✅ runs
} catch (error) {
console.log("C"); // ❌ skipped
} finally {
console.log("D"); // ✅ always runs
}
// Output: A, B, D
// Path 2: Error at line A
try {
undefinedVar; // 💥 error — jumps to catch
console.log("B"); // ❌ skipped (never reached)
} catch (error) {
console.log("C"); // ✅ runs
} finally {
console.log("D"); // ✅ always runs
}
// Output: C, D
// Path 3: try without catch (just finally)
try {
console.log("A"); // ✅ runs
} finally {
console.log("D"); // ✅ always runs
}
// Output: A, D
Throwing Custom Errors
JavaScript lets you create and throw your own errors using the throw statement. This is powerful for enforcing rules in your code.
Basic Custom Throw
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)); // 💥 throws!
} catch (error) {
console.log("Error:", error.message);
// "Error: Cannot divide by zero!"
}
Validating User Input
function createUser(name, age) {
if (!name || name.trim() === "") {
throw new Error("Name is required!");
}
if (typeof age !== "number" || age < 0) {
throw new Error("Age must be a positive number!");
}
if (age < 13) {
throw new Error("User must be at least 13 years old!");
}
return { name: name.trim(), age, createdAt: new Date() };
}
try {
const user1 = createUser("Pratham", 22);
console.log("Created:", user1);
// Created: { name: "Pratham", age: 22, createdAt: ... }
const user2 = createUser("", 25);
// 💥 throws "Name is required!"
} catch (error) {
console.log("Validation failed:", error.message);
// "Validation failed: Name is required!"
}
Throwing Specific Error Types
function processAge(age) {
if (typeof age !== "number") {
throw new TypeError("Age must be a number!");
}
if (age < 0 || age > 150) {
throw new RangeError("Age must be between 0 and 150!");
}
return `Age is valid: ${age}`;
}
try {
console.log(processAge(22)); // "Age is valid: 22"
console.log(processAge("twenty")); // throws TypeError
} catch (error) {
if (error instanceof TypeError) {
console.log("Type problem:", error.message);
} else if (error instanceof RangeError) {
console.log("Range problem:", error.message);
} else {
console.log("Unknown error:", error.message);
}
}
Using instanceof lets you handle different error types differently — like routing errors to different recovery strategies.
Why Error Handling Matters
1. Your App Doesn't Crash
Without error handling, one error kills the entire script. With it, the error is contained and the rest of your code keeps running.
// ❌ Without handling — everything stops
const data = JSON.parse(invalidJSON); // crash
updateUI(); // never runs
saveToDatabase(); // never runs
// ✅ With handling — app continues
try {
const data = JSON.parse(invalidJSON);
} catch (e) {
showFallbackUI();
}
updateUI(); // still runs
saveToDatabase(); // still runs
2. Users See Friendly Messages
// ❌ User sees: "TypeError: Cannot read properties of undefined"
// 😱 Confusing and scary
// ✅ User sees: "Something went wrong. Please try again."
// 😌 Professional and helpful
3. Debugging Becomes Easier
When you catch errors properly, you can log detailed information for developers while showing simple messages to users:
try {
riskyOperation();
} catch (error) {
// For developers (in console/logs)
console.error(`[${error.name}] ${error.message}`);
console.error(error.stack);
// For users (on screen)
showMessage("Something went wrong. Please try again.");
}
4. Graceful Degradation
Instead of a broken page, you show fallback content:
async function loadProfile() {
try {
const response = await fetch("/api/profile");
const profile = await response.json();
displayProfile(profile);
} catch (error) {
// API is down? Show cached data instead.
const cached = localStorage.getItem("profile");
if (cached) {
displayProfile(JSON.parse(cached));
showNotice("Showing cached data — couldn't reach the server.");
} else {
showError("Unable to load your profile. Please check your connection.");
}
}
}
The Benefits — Summarized
| Without Error Handling | With Error Handling |
|---|---|
| App crashes on any error | App continues running |
| User sees cryptic error messages | User sees friendly messages |
| No information about what failed | Detailed logs for debugging |
| Broken state left behind | Resources cleaned up in finally
|
| All-or-nothing execution | Graceful degradation with fallbacks |
Let's Practice: Hands-On Assignment
Part 1: Basic Try/Catch
function safeParse(jsonString) {
try {
const result = JSON.parse(jsonString);
console.log("Parsed successfully:", result);
return result;
} catch (error) {
console.log("Invalid JSON:", error.message);
return null;
}
}
safeParse('{"name": "Pratham"}'); // Parsed successfully: { name: "Pratham" }
safeParse("not json at all"); // Invalid JSON: ...
safeParse(""); // Invalid JSON: Unexpected end of JSON input
Part 2: Custom Error Throwing
function withdraw(balance, amount) {
if (amount <= 0) {
throw new Error("Amount must be positive!");
}
if (amount > balance) {
throw new Error("Insufficient funds!");
}
return balance - amount;
}
try {
console.log(withdraw(1000, 500)); // 500
console.log(withdraw(1000, 1500)); // throws!
} catch (error) {
console.log("Transaction failed:", error.message);
// "Transaction failed: Insufficient funds!"
}
Part 3: Try/Catch/Finally Together
function processData(data) {
console.log("⏳ Processing started...");
try {
if (!data) throw new Error("No data provided!");
const parsed = JSON.parse(data);
console.log(`✅ Processed: ${parsed.name}`);
return parsed;
} catch (error) {
console.log(`❌ Error: ${error.message}`);
return null;
} finally {
console.log("🏁 Processing complete (cleanup done).");
}
}
processData('{"name": "Pratham"}');
// ⏳ Processing started...
// ✅ Processed: Pratham
// 🏁 Processing complete (cleanup done).
processData(null);
// ⏳ Processing started...
// ❌ Error: No data provided!
// 🏁 Processing complete (cleanup done).
Part 4: Error Handling with Async/Await
async function fetchUser(userId) {
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const user = await response.json();
console.log(`✅ User: ${user.name} (${user.email})`);
} catch (error) {
console.log(`❌ Failed: ${error.message}`);
} finally {
console.log("🏁 Fetch attempt complete.");
}
}
fetchUser(1); // Success
fetchUser(9999); // Might fail depending on API
Key Takeaways
-
Errors are runtime problems —
TypeError,ReferenceError,RangeError, etc. Without handling, they crash your entire script. -
try/catchlets you attempt risky code and handle failures gracefully. The program continues instead of crashing. -
finallyruns no matter what — success or failure. Use it for cleanup: hiding spinners, closing connections, resetting state. -
throwlets you create custom errors for input validation, business rules, or any situation where you want to signal a problem explicitly. - Error handling isn't optional — it's what separates a script that works in ideal conditions from software that works in the real world.
Wrapping Up
Errors aren't bugs — they're expected. APIs fail. Users type garbage. JSON gets corrupted. The mark of a good developer isn't writing code that never errors — it's writing code that handles errors gracefully. try/catch/finally is your toolbox for exactly that.
I'm building this mindset through the ChaiCode Web Dev Cohort 2026 under Hitesh Chaudhary and Piyush Garg. Error handling became critical the moment we started working with fetch, async/await, and form validation — basically, the moment our code started interacting with the real world. Get comfortable with it now and your apps will be rock solid.
Connect with me on LinkedIn or visit PrathamDEV.in. More articles on the way as I keep building and learning.
Happy coding! 🚀
Written by Pratham Bhardwaj | Web Dev Cohort 2026, ChaiCode
Top comments (0)