The Night My Server Died at 3 AM
Three days after launching my first side project, I got a Slack notification at 3 AM. The server was down. I frantically opened the logs and found just one line: TypeError: Cannot read property 'id' of undefined, followed by the process termination message. A user tried to upload a profile image, the file size validation logic threw an error, and because no one caught that error, the entire server crashed.
That's when I realized: errors are inevitable. The question isn't whether errors will happen, but whether your program dies or handles them gracefully. Exception handling wasn't optional—it was mandatory. This was one of the truths I, as a non-CS major, learned the hard way.
The Confusion of Encountering My First Errors
When I first learned to code, I only thought about the "happy path." I assumed users always entered valid input, APIs always returned 200, and files always existed. Reality was different.
- Users type "asdf" into email fields
- External APIs suddenly return 503
- Database connections timeout
- JSON parsing fails because the format is corrupted
- Sometimes the disk is full and you can't even write logs
I spent more time debugging "why isn't this working?" than actually writing code. That's when I understood: the difference between good and bad developers is how well they anticipate and prepare for errors.
Finally Understanding the Essence of try-catch
When I first learned try-catch, I thought it was just "catch errors when they happen." But using it in production, I realized it wasn't just an error handling tool—it was a control flow structure.
This analogy clicked for me: try-catch is like the safety net under a circus trapeze.
- try: The act of performing on the trapeze. Risky, but the show must go on.
- catch: The safety net that catches you when you fall. You don't die from the fall.
- finally: The stage crew that cleans up and turns off the lights, whether the performance succeeded or failed.
Whether the show goes perfectly or someone falls, the stage must be cleaned. That's what finally does.
Learning to Distinguish Error Types
Working with JavaScript, all errors looked the same at first. Red text appeared and I'd just think "error happened." But errors have types.
SyntaxError - When syntax is wrong
// Missing closing quote
const message = "Hello;
// SyntaxError: Invalid or unexpected token
This blows up during the parsing phase, before execution even starts. You can't even catch it with try-catch because the code isn't valid JavaScript.
TypeError - When types don't match
const user = null;
console.log(user.name); // TypeError: Cannot read property 'name' of null
The error I encountered most often. "I thought this was an object but it's null." Happens constantly when API responses differ from expectations.
ReferenceError - Referencing non-existent variables
console.log(notDefined); // ReferenceError: notDefined is not defined
Typos in variable names, or trying to access variables outside their scope.
RangeError - Values outside allowed range
const arr = new Array(-1); // RangeError: Invalid array length
Negative array lengths, or stack overflow from too-deep recursion.
I understood these as "bombs that explode during execution." The code is syntactically valid, but something goes wrong at runtime. And these bombs can be caught with try-catch.
The Shock of Checked vs Unchecked in Java
I moved from JavaScript/Node.js to Java Spring at work. Java's "Checked Exception" concept stunned me.
Checked Exception - Compiler-enforced exception handling
// This won't compile!
public void readFile(String path) {
FileReader reader = new FileReader(path); // Compile Error!
// Unhandled exception: java.io.FileNotFoundException
}
// Either use try-catch
public void readFile(String path) {
try {
FileReader reader = new FileReader(path);
} catch (FileNotFoundException e) {
System.out.println("File not found: " + e.getMessage());
}
}
// Or delegate with throws
public void readFile(String path) throws FileNotFoundException {
FileReader reader = new FileReader(path);
}
Java forces you: "This method can throw FileNotFoundException, so you must handle it." JavaScript just crashes if something goes wrong, but Java requires exception handling at compile time.
Initially annoying. "Why are you forcing this on me?" But as the project grew, I understood. Every point where an exception can occur is explicitly documented in the code, so during maintenance I don't have to wonder "where could errors happen?"
Unchecked Exception - RuntimeException family
// This compiles without handling
int result = 10 / 0; // ArithmeticException (subclass of RuntimeException)
RuntimeException and its subclasses (NullPointerException, ArrayIndexOutOfBoundsException, etc.) aren't checked. Why? Because they represent programmer mistakes. Don't catch these with try-catch—fix your code.
I organized it like this:
- Checked Exception: External factors (missing file, network down). Predictable and recoverable, so handle it.
- Unchecked Exception: Programmer mistakes (null reference, index out of bounds). Fix your code.
Creating Custom Error Classes
As projects grew, generic "Error" wasn't enough. When API requests failed, I needed to know if it was a network issue, authentication failure, or server error.
// Before: Everything is just Error
throw new Error("API request failed");
// After: Granular errors
class NetworkError extends Error {
constructor(message) {
super(message);
this.name = "NetworkError";
this.statusCode = 0; // Network itself is down
}
}
class AuthenticationError extends Error {
constructor(message) {
super(message);
this.name = "AuthenticationError";
this.statusCode = 401;
}
}
class ServerError extends Error {
constructor(message, statusCode) {
super(message);
this.name = "ServerError";
this.statusCode = statusCode;
}
}
This allowed different handling for different error types in catch blocks.
try {
await api.fetchUserProfile();
} catch (error) {
if (error instanceof NetworkError) {
showToast("Please check your internet connection");
} else if (error instanceof AuthenticationError) {
redirectToLogin();
} else if (error instanceof ServerError) {
logToSentry(error);
showToast("Server error. Please try again later");
} else {
console.error("Unknown error:", error);
showToast("An unknown error occurred");
}
}
I thought of this as "giving errors identification cards." Errors introduce themselves, so I can respond appropriately.
Understanding Error Propagation
Initially I thought "do I need try-catch everywhere?" No. Errors propagate up the call stack.
function validateEmail(email) {
if (!email.includes("@")) {
throw new Error("Invalid email format");
}
}
function processUserInput(formData) {
validateEmail(formData.email); // If error happens here
// Code below doesn't execute
saveToDatabase(formData);
}
function handleSubmit() {
try {
processUserInput({ email: "invalid" }); // Error propagates here
} catch (error) {
console.error("Input processing failed:", error.message);
}
}
When validateEmail throws an error, it skips processUserInput and goes straight to handleSubmit's catch block. Like when a fire alarm goes off, you skip all floors and go straight to the ground floor exit.
After understanding this, I started throwing errors in business logic and catching only at the controller or top level.
Struggling with Async Code
When first learning Promises, error handling was most confusing.
// This doesn't catch the error!
try {
fetch("https://api.example.com/data")
.then(res => res.json())
.then(data => console.log(data));
} catch (error) {
// Never reaches here!
console.error(error);
}
Promises execute asynchronously, so try-catch doesn't work. By the time fetch executes, we've already exited the try block.
Use .catch() with Promises
fetch("https://api.example.com/data")
.then(res => {
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
return res.json();
})
.then(data => console.log(data))
.catch(error => {
// Catches errors here
console.error("API request failed:", error);
})
.finally(() => {
// Runs whether success or failure
hideLoadingSpinner();
});
With async/await, try-catch works
async function fetchUserData() {
try {
const response = await fetch("https://api.example.com/user");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to load user data:", error);
return null; // Return default value
} finally {
hideLoadingSpinner();
}
}
Using async/await, I felt "finally, I can handle errors in async code like sync code." The code became much more readable.
Building the Last Line of Defense with Global Error Handlers
No matter how well you use try-catch, some errors slip through. So I set up a final safety net with global error handlers.
In Node.js
// Synchronous errors
process.on("uncaughtException", (error) => {
console.error("Uncaught exception:", error);
logErrorToFile(error);
// Process state may be unstable
process.exit(1);
});
// Promise rejections
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled promise rejection:", reason);
logErrorToSentry(reason);
});
In browsers
window.addEventListener("error", (event) => {
console.error("Global error:", event.error);
Sentry.captureException(event.error);
});
window.addEventListener("unhandledrejection", (event) => {
console.error("Unhandled promise error:", event.reason);
Sentry.captureException(event.reason);
});
But global handlers are the last resort. Errors reaching here mean program state might be unstable. All you can do is log and gracefully shut down.
Establishing Error Logging Strategy
If you just "console.log and done," you have nothing when debugging later. I established these principles:
1. Log error context
try {
await updateUserProfile(userId, newData);
} catch (error) {
// Bad: Only log the error
console.error(error);
// Good: Include context
console.error("User profile update failed", {
userId,
attemptedData: newData,
error: error.message,
stack: error.stack,
timestamp: new Date().toISOString()
});
}
2. Distinguish error levels
// Warning level (recoverable)
logger.warn("Cache refresh failed, using default", { error });
// Error level (feature broken)
logger.error("Payment processing failed", { error, orderId });
// Fatal level (entire service down)
logger.fatal("Database connection unavailable", { error });
3. Don't log sensitive information
// Bad: Password ends up in logs
console.error("Login failed", { email, password });
// Good: Exclude sensitive info
console.error("Login failed", { email, errorCode: error.code });
Real-World Example: API Error Handling
The most common pattern I use in production:
async function fetchWithErrorHandling(url, options = {}) {
try {
const response = await fetch(url, options);
// Check HTTP status code
if (!response.ok) {
// 4xx: Client error
if (response.status >= 400 && response.status < 500) {
const errorData = await response.json();
throw new AuthenticationError(errorData.message);
}
// 5xx: Server error
if (response.status >= 500) {
throw new ServerError(`Server error (${response.status})`);
}
}
const data = await response.json();
return { success: true, data };
} catch (error) {
// Network itself is down
if (error instanceof TypeError && error.message.includes("fetch")) {
return {
success: false,
error: new NetworkError("Please check your internet connection")
};
}
// Our custom errors pass through
if (error instanceof NetworkError ||
error instanceof AuthenticationError ||
error instanceof ServerError) {
return { success: false, error };
}
// Unexpected error
console.error("Unexpected error:", error);
return {
success: false,
error: new Error("An unknown error occurred")
};
}
}
// Usage
const result = await fetchWithErrorHandling("/api/user/profile");
if (result.success) {
displayProfile(result.data);
} else {
showErrorMessage(result.error.message);
}
Form Validation Error Handling
class ValidationError extends Error {
constructor(field, message) {
super(message);
this.name = "ValidationError";
this.field = field;
}
}
function validateLoginForm(formData) {
if (!formData.email) {
throw new ValidationError("email", "Please enter your email");
}
if (!formData.email.includes("@")) {
throw new ValidationError("email", "Invalid email format");
}
if (!formData.password) {
throw new ValidationError("password", "Please enter your password");
}
if (formData.password.length < 8) {
throw new ValidationError("password", "Password must be at least 8 characters");
}
}
function handleLogin(formData) {
try {
validateLoginForm(formData);
await login(formData);
} catch (error) {
if (error instanceof ValidationError) {
showFieldError(error.field, error.message);
} else {
showToast("Login failed");
}
}
}
Database Connection Failure Handling
async function connectDatabase(retries = 3) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const db = await mongoose.connect(process.env.DB_URL);
console.log("DB connection successful");
return db;
} catch (error) {
console.error(`DB connection failed (attempt ${attempt}/${retries})`, error);
if (attempt === retries) {
throw new Error("Failed to connect to database");
}
// Wait before retry (exponential backoff)
const waitTime = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, waitTime));
}
}
}
Anti-patterns to Avoid
1. Empty catch blocks (swallowing errors)
// Bad: You don't even know an error occurred
try {
riskyOperation();
} catch (error) {
// Do nothing
}
// Good: At least log it
try {
riskyOperation();
} catch (error) {
console.error("riskyOperation failed:", error);
}
2. Treating all errors the same
// Bad: All errors handled identically
try {
await api.call();
} catch (error) {
showToast("An error occurred");
}
// Good: Handle by error type
try {
await api.call();
} catch (error) {
if (error.status === 401) {
redirectToLogin();
} else if (error.status === 500) {
showToast("Server error");
logToSentry(error);
} else {
showToast("Request failed: " + error.message);
}
}
3. Hiding unrecoverable errors with catch
// Bad: Hiding programmer mistakes
try {
const result = someUndefinedVariable.property;
} catch (error) {
// Should fix the code, but hiding it instead
}
// Good: Let these crash during development so you find them
What It All Comes Down To
Exception handling isn't just "catch errors when they happen." It's about maintaining control when your program faces unpredictable situations. When writing code now, I always think:
- "What could go wrong here?"
- "When things go wrong, what should I show the user?"
- "Is this error recoverable, or should the program terminate?"
Try is attempting with hope, catch is acknowledging failure and responding, finally is taking responsibility regardless of outcome. These three together create stable software.
Since that night my server died at 3 AM, I add exception handling to all critical logic. Errors are unavoidable, but disasters from errors are preventable. That's the essence of exception handling I've embraced.