Handling Edge Cases: Thinking Beyond the Happy Path
Demo went great. Client loved it. We shipped to production. First real user: empty cart, clicked checkout, white screen. We'd never tested with zero items.
The happy path—user adds items, enters payment, sees confirmation—worked perfectly. But the first person to deviate from that script broke the app.
I've learned to spend more time thinking about what can go wrong than what should go right. The happy path is maybe 30% of the work. Edge cases are the other 70%.
The Empty Case
The most common edge case is nothing. Empty arrays, null values, blank strings.
// ❌ Assumes data exists
function getFirstUserName(users) {
return users[0].name.toUpperCase();
}
// Crashes when users is [] or undefined
// ✅ Handles emptiness
function getFirstUserName(users) {
if (!users || users.length === 0) {
return 'Guest';
}
return users[0].name.toUpperCase();
}
Guard clauses at the top of functions catch these early. If data is missing, return a sensible default or throw a clear error before the main logic runs.
The Boundary Case
What happens at the edges of valid input?
- Pagination: page 0? page -1? page 999999?
- Quantity: 0 items? negative? 10,000?
- Dates: February 29? December 32? Year 0?
// ❌ No boundaries
function addToCart(quantity) {
cart.total += quantity;
}
// ✅ Enforced boundaries
function addToCart(quantity) {
if (quantity <= 0) {
throw new Error("Quantity must be positive");
}
if (quantity > 99) {
throw new Error("Max 99 items per order");
}
cart.total += quantity;
}
Always test: 0, 1, max value, max + 1, negative. These are where off-by-one errors hide.
The Network Failure Case
Your code might be perfect. The world around it isn't. APIs return 500 errors. Networks time out. JSON comes back malformed.
async function fetchConfig() {
try {
const response = await fetch('/api/config');
if (!response.ok) {
// Server error - use fallback
console.warn("API error, using defaults");
return DEFAULT_CONFIG;
}
return await response.json();
} catch (error) {
// Network error or bad JSON
console.error("Fetch failed, using defaults");
return DEFAULT_CONFIG;
}
}
Never assume fetch() will succeed. Never assume JSON.parse() won't throw. Have fallbacks. Degrade gracefully instead of showing a white screen.
The Impatient User Case
Users do weird things:
- Double-click submit buttons
- Paste megabytes of text into input fields
- Click back while a form is submitting
- Enter emoji in phone number fields
let isSubmitting = false;
async function handleSubmit() {
if (isSubmitting) return; // Block double-click
isSubmitting = true;
submitButton.disabled = true;
try {
await submitForm();
} finally {
isSubmitting = false;
submitButton.disabled = false;
}
}
And never trust client-side validation alone. The backend must also reject invalid input—someone will bypass your UI.
How to Find Edge Cases
When writing any function, pause and ask:
- Type: What if it's the wrong type? String instead of number?
- Value: What if it's empty? Null? Negative? Huge?
- State: What if user is logged out? Offline? Mid-session-timeout?
- Timing: What if this runs twice? What if something else runs first?
You don't need to handle every theoretical edge case. But you should consciously decide which ones matter and which ones you're okay ignoring.
Fail Fast, Fail Clearly
When you do hit an edge case you can't handle, fail immediately with a clear message:
function processOrder(order) {
if (!order) {
throw new Error("processOrder requires an order object");
}
if (!order.items?.length) {
throw new Error("Order must have at least one item");
}
// Now we know order exists and has items
}
Vague failures deep in the code are hard to debug. Explicit failures at the boundary are easy to trace.
The Mindset Shift
The happy path is for demos. Production code lives in a world where users do unexpected things, networks fail, and data is messy.
Think about how your code can break before you write it. You'll spend less time debugging later.