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?

// ❌ 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:

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:

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.

← Back to Debugging & Code Quality

Back to Home