Error Handling & Debugging
Why error handling matters
Every program encounters errors. Users type unexpected input, APIs go down, network connections drop. The difference between a good app and a bad one is how it handles these situations.
Types of errors
Syntax errors
The code is written incorrectly — JavaScript can't even parse it:
JavaScript
// Missing closing parenthesis
console.log('hello'
// SyntaxError: Unexpected end of inputYour editor catches most of these before you run the code.
Runtime errors
The code is valid but something goes wrong during execution:
JavaScript
const user = null;
console.log(user.name);
// TypeError: Cannot read properties of null (reading 'name')Logical errors
The code runs without crashing but produces wrong results:
JavaScript
function calculateDiscount(price, percent) {
return price * percent; // Bug: should be price * (percent / 100)
}
calculateDiscount(100, 20); // 2000, not 20These are the hardest to find because JavaScript doesn't tell you anything is wrong.
try/catch/finally
JavaScript
try {
const data = JSON.parse(userInput);
processData(data);
} catch (error) {
console.error('Invalid JSON:', error.message);
showUserError('Please enter valid data.');
} finally {
hideLoadingSpinner();
}try— wraps code that might fail.catch— runs if an error occurs. Theerrorobject hasmessageandstackproperties.finally— always runs, whether there was an error or not. Perfect for cleanup.
When to use try/catch
Use it around operations that can fail for reasons outside your control:
- Parsing JSON (
JSON.parse) - Network requests (
fetch) - Accessing storage (
localStorage) - Third-party library calls
Throwing custom errors
JavaScript
function divide(a, b) {
if (b === 0) {
throw new Error('Cannot divide by zero');
}
return a / b;
}
try {
divide(10, 0);
} catch (error) {
console.error(error.message); // "Cannot divide by zero"
}Custom error classes
For larger apps, create specific error types:
JavaScript
class ValidationError extends Error {
constructor(field, message) {
super(message);
this.name = 'ValidationError';
this.field = field;
}
}
class NotFoundError extends Error {
constructor(resource) {
super(`${resource} not found`);
this.name = 'NotFoundError';
this.status = 404;
}
}
function getUser(id) {
const user = database.find(u => u.id === id);
if (!user) throw new NotFoundError('User');
return user;
}Now you can catch specific error types:
JavaScript
try {
const user = getUser(999);
} catch (error) {
if (error instanceof NotFoundError) {
showNotFound();
} else if (error instanceof ValidationError) {
showValidationError(error.field, error.message);
} else {
throw error; // re-throw unexpected errors
}
}Async error handling
With
async/await, use try/catch:JavaScript
async function loadData() {
try {
const response = await fetch('/api/data');
if (!response.ok) {
throw new Error(`Server returned ${response.status}`);
}
return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
console.log('Request was cancelled');
} else {
console.error('Failed to load:', error.message);
throw error;
}
}
}Debugging with console
Beyond
console.log, there are better tools:JavaScript
console.error('Something broke'); // red output
console.warn('Careful here'); // yellow output
console.table([{ a: 1 }, { a: 2 }]); // formatted table
console.group('User data'); // collapsible group
console.log('Name:', name);
console.log('Age:', age);
console.groupEnd();
console.time('fetch'); // start timer
await fetch('/api/data');
console.timeEnd('fetch'); // "fetch: 142ms"Debugging with browser DevTools
Breakpoints
Instead of adding
console.log everywhere, set breakpoints:- Open DevTools (F12).
- Go to the Sources tab.
- Click a line number to set a breakpoint.
- When execution hits that line, it pauses.
- Inspect variables in the Scope panel.
- Step through code line-by-line.
debugger; in your code:JavaScript
function processOrder(order) {
debugger; // execution pauses here when DevTools is open
const total = calculateTotal(order);
return total;
}Network tab
The Network tab shows every HTTP request your app makes. You can see:
- Request/response headers
- Request body
- Response data
- Timing (how long each request took)
- Status codes
Defensive coding patterns
Guard clauses
JavaScript
function processUser(user) {
if (!user) return;
if (!user.email) return;
sendEmail(user.email);
}Return early for invalid states instead of nesting everything in if/else.
Default values
JavaScript
function greet(name = 'stranger') {
return `Hello, ${name}!`;
}Input validation
JavaScript
function createAccount(email, password) {
if (typeof email !== 'string' || !email.includes('@')) {
throw new ValidationError('email', 'Invalid email address');
}
if (!password || password.length < 8) {
throw new ValidationError('password', 'Password must be at least 8 characters');
}
}Logging and monitoring in real apps
console.log is fine while learning, but production apps benefit from structured logging: include a timestamp, the operation name, and enough context to reproduce the issue without logging passwords or tokens. Log errors at the point you catch them, then either recover gracefully or re-throw if the caller must know.When an error is unexpected, preserve the stack trace. Passing
error to console.error(error) or re-throwing with throw error keeps debugging information. Wrapping with throw new Error('Failed') without chaining loses the original stack unless you use cause: `throw new Error('Failed to load user',{
cause: error })
.
Testing your error paths
It is easy to test the happy path and forget failures. Deliberately trigger errors during development: disconnect the network, send malformed JSON, pass null where an object is expected. Each failure mode should produce a clear user-facing message and leave the app in a safe state — forms should not stay stuck on "Loading...", and partial data should not overwrite good data.
Unit tests can assert that functions throw the right error type (expect(() => divide(1, 0)).toThrow('Cannot divide by zero')). Integration tests can mock fetch to return 500 responses and verify your UI shows an error banner.
Fail gracefully, not silently
Swallowing errors in empty catch blocks is one of the worst habits in JavaScript. If you cannot recover, at least log the error. If the user needs to act, show a message. Empty catches make production bugs nearly impossible to diagnose.
Key takeaway
Good error handling means anticipating what can go wrong and providing useful feedback. Use try/catch for operations that can fail externally, throw custom errors for domain logic, use guard clauses for defensive coding, and learn your browser DevTools — they're more powerful than console.log`.