Sid Ngeth's Blog A blog about anything (but mostly development)

Print Debugging

The Paradox of Choice was popularized by psychologist Barry Schwartz in his 2004 book of the same name, though the underlying concept has roots in earlier psychological and economic research.

Schwartz argued against the conventional wisdom that more options always lead to better outcomes and greater satisfaction. Instead, he demonstrated through various studies that beyond a certain point, additional choices can lead to decision paralysis, increased anxiety, and decreased satisfaction with whatever choice is ultimately made. His famous example involved a study of jam purchases at a grocery store: when customers were presented with 24 varieties of jam, fewer people made purchases compared to when only 6 varieties were available, and those who did buy from the larger selection were less satisfied with their choice.

This principle perfectly explains why developers default to print debugging despite having superior tools available. When faced with a bug, modern IDEs offer an overwhelming array of sophisticated options: integrated debuggers with breakpoints, step-through execution, variable watches, call stack inspection, conditional breakpoints, and profiling tools. Each tool requires learning its interface, making decisions about where to set breakpoints, and understanding how to interpret the results. This cognitive overhead creates decision paralysis, so developers instinctively retreat to the simplest option that requires zero choices: scattering print statements throughout their code. It’s the debugging equivalent of ordering the same meal at a restaurant with a 50-page menu - the familiar option feels safer than navigating all the potentially better alternatives, even when you know those alternatives would ultimately serve you better.

Debugger interface showing variable inspection

Every developer has reached for console.log or other similiar print statements when code misbehaves. You sprinkle logs throughout your functions, refresh the browser, and squint at the console trying to piece together what went wrong. While this approach feels familiar, it’s one of the least efficient ways to debug code, especially when tracing complex program flow or call stacks.

At its core, console.log debugging is just an inefficient, stone-age version of watch variables. You’re essentially trying to manually recreate what modern debuggers do automatically - except you have to guess what to watch, modify your source code, and parse through cluttered output instead of seeing clean, real-time variable updates.

The Three Core Problems with Print Debugging

First, you’re debugging blind. When tracking down bugs in deep call stacks, you’re playing a guessing game about where to place diagnostic output. You don’t know the actual execution path, so you scatter logs based on hunches. Consider this recursive function that’s causing stack overflows:

// Initial version - just crashes somewhere
function processNestedData(data, depth = 0) {
    if (!data) return null;

    if (data.children) {
        return data.children.map(child => processNestedData(child, depth + 1));
    }

    return data.value;
}

// First debugging attempt - add some logs
function processNestedData(data, depth = 0) {
    console.log('processNestedData called with:', data, 'depth:', depth);

    if (!data) {
        console.log('data is null/undefined, returning');
        return null;
    }

    console.log('about to check children');
    if (data.children) {
        console.log('has children, recursing');
        return data.children.map(child => processNestedData(child, depth + 1));
    }

    console.log('returning value:', data.value);
    return data.value;
}

// Still crashing, need more detail
function processNestedData(data, depth = 0) {
    console.log(`[DEPTH ${depth}] Processing:`, JSON.stringify(data));

    if (!data) return null;

    console.log(`[DEPTH ${depth}] Data type:`, typeof data);
    console.log(`[DEPTH ${depth}] Has children:`, !!data.children);

    if (data.children) {
        console.log(`[DEPTH ${depth}] Children count:`, data.children.length);
        console.log(`[DEPTH ${depth}] About to map over children`);

        const result = data.children.map((child, index) => {
            console.log(`[DEPTH ${depth}] Processing child ${index}:`, child);
            return processNestedData(child, depth + 1);
        });

        console.log(`[DEPTH ${depth}] Finished processing children`);
        return result;
    }

    return data.value;
}

You’re stuck in an endless cycle of adding logs, refreshing, running, and still not knowing exactly where the circular reference is or when the infinite recursion starts. Console.log forces you to predict what variables matter, while debugger watch variables let you inspect anything on demand.

Second, the development cycle kills productivity. Each investigation requires modifying source code, rebuilding (if using a bundler), and reloading. Modern build tools might be fast, but you’re still waiting to see if your latest batch of console.log statements provides useful insight. When they don’t—which happens frequently—you’re back to modifying code and waiting for another reload cycle. Look at how this simple bug hunt spirals out of control:

// First attempt - add some logs
async function fetchUserProfile(userId) {
    console.log('Fetching profile for user:', userId);  // Round 1

    const response = await fetch(`/api/users/${userId}`);
    const userData = await response.json();

    if (validateUserData(userData)) {
        updateUserCache(userData);
        renderUserProfile(userData);
    }
}

// Still not clear what's wrong, add more logs
async function fetchUserProfile(userId) {
    console.log('Fetching profile for user:', userId);
    console.log('User ID type:', typeof userId, 'Value:', userId);  // Round 2

    const response = await fetch(`/api/users/${userId}`);
    console.log('Response status:', response.status);  // Round 2
    console.log('Response headers:', response.headers);  // Round 2

    const userData = await response.json();
    console.log('Parsed user data:', userData);  // Round 2

    if (validateUserData(userData)) {
        console.log('Validation passed');  // Round 2
        updateUserCache(userData);
        renderUserProfile(userData);
    } else {
        console.log('Validation failed');  // Round 2
    }
}

// Still need to dig deeper, modify validation function
function validateUserData(userData) {
    console.log('Validating user data:', userData);  // Round 3
    console.log('Has email:', !!userData.email);  // Round 3
    console.log('Email value:', userData.email);  // Round 3

    if (!userData.email || userData.email.length === 0) {
        console.log('Email validation failed');  // Round 3
        return false;
    }
    // ... more validation logic with more logs
}

This is the stone age approach to variable inspection. You’re manually recreating what watch variables do automatically, but with all the overhead of code modification and none of the flexibility.

Third, you lose the bigger picture. Console.log debugging only shows you the specific points you thought to instrument, potentially reinforcing incorrect assumptions about how your code behaves. It’s like trying to watch a movie by taking random screenshots - you miss the flow and context. Consider this Promise chain where the bug could be anywhere:

function handleUserAction(actionType, payload) {
    console.log('Handling action:', actionType, payload);

    switch(actionType) {
        case 'LOGIN':
            return authenticateUser(payload)
                .then(result => {
                    console.log('Auth result:', result);  // Is this even called?
                    return updateUserSession(result);
                })
                .then(session => {
                    console.log('Session updated:', session);  // What about this?
                    return redirectToProfile(session.userId);
                });
        case 'LOGOUT':
            return clearUserSession()
                .then(() => {
                    console.log('Session cleared');  // Silent failure?
                    return redirectToHome();
                });
    }
}

async function authenticateUser(credentials) {
    console.log('Authenticating with:', credentials.username);
    // Where do I put the next log? In the API call? In error handling?
    try {
        const response = await fetch('/api/auth', {
            method: 'POST',
            body: JSON.stringify(credentials)
        });
        return await response.json();
    } catch (error) {
        console.log('Auth error:', error);  // But what kind of error?
        throw error;
    }
}

You’re instrumenting based on assumptions, potentially missing the real problem entirely.

The Three Advantages of Interactive Debugging

Start high and step down systematically. With a debugger, you set one breakpoint and step through the actual execution path. Watch variables show you real-time updates without any code modification:

function processNestedData(data, depth = 0) {
    if (!data) return null;  // <- Breakpoint here, add 'data' to watch

    if (data.children) {     // <- Step through, watch 'data.children' update
        return data.children.map(child => processNestedData(child, depth + 1));
    }

    return data.value;
}

In your browser debugger watch panel, you immediately see the problem:

▼ data: Object
  ▼ children: Array(1)
    ▼ 0: Object
        value: "child"
      ▼ parent: Object [Circular *1]  <- Circular reference detected!

No code modification needed. No parsing through console spam. Just clean, structured variable inspection.

Adapt your investigation in real time. Instead of guessing what to log, you examine variables on demand and add new watches instantly:

async function fetchUserProfile(userId) {
    // Breakpoint here - add userId to watch, see it's undefined
    const response = await fetch(`/api/users/${userId}`);  // Add response to watch
    const userData = await response.json();  // Add userData to watch

    if (validateUserData(userData)) {  // Step into this, watch validation logic
        updateUserCache(userData);     // Watch cache updates in real-time
        renderUserProfile(userData);
    }
}

In the debugger watch window (updated in real-time as you step):

userId: undefined        <- Added to watch when you noticed the problem
response.status: 404     <- Added when you stepped to this line
userData: {error: "User not found"}  <- Added automatically as you stepped
validateUserData(userData): false    <- Added to watch the function result

This is like having a time machine for your variables - you can watch how they change over time without having to predict what you’ll need to see.

See the complete context. For complex async flows, you can trace the entire chain while watching variables update across the entire call stack:

function handleUserAction(actionType, payload) {
    // Breakpoint here, step through the switch
    // Watch panel shows: actionType: "LOGIN", payload: {username: "john"}
    switch(actionType) {
        case 'LOGIN':
            return authenticateUser(payload)  // Step into, watch variables flow down
                .then(result => updateUserSession(result))  // Watch result propagate
                .then(session => redirectToProfile(session.userId));
    }
}

async function authenticateUser(credentials) {
    // Watch panel now shows: credentials: {username: "john", password: "***"}
    const response = await fetch('/api/auth', {
        method: 'POST',
        body: JSON.stringify(credentials)
    });
    // Watch panel updates: response: Response {status: 200, ok: true, ...}
    return await response.json();
}

Your debugger shows the complete call stack and async flow, with variables updating in real-time across all scopes.

The transition from print debugging requires some initial learning investment, but the productivity gains are substantial.

Modern browsers make this easier than ever, with powerful debugging tools built right into DevTools. The time spent learning these tools pays dividends in every debugging session thereafter, turning what used to be a time-consuming guessing game into a systematic process of investigation and understanding.

comments powered by Disqus