Logical Reasoning

Debugging Challenges

19 Questions

Off-by-one errors (OBOEs) are among the most common bugs: your loop runs one iteration too many or too few, or you access index n when you should access n-1.

The key question: should your boundary condition use < or <=, should array index start at 0 or 1, and should you process the last element or stop before it?

Test with edge cases n=0 and n=1 immediately — OBOEs usually reveal themselves at boundaries before they appear in larger inputs.

// Off-by-one in loop (common)
for (let i = 0; i <= arr.length; i++) // bug: should be i < arr.length
  console.log(arr[i]);               // arr[arr.length] is undefined!

// Off-by-one in slice
const first3 = arr.slice(0, 3);  // indices 0,1,2 (NOT including 3)

// Classic: count elements in range [a, b]
const count = b - a + 1; // +1 to include BOTH endpoints

// Verify boundary: does this iterate 3 times for arr=[1,2,3]?
for (let i = 0; i < 3; i++) { /* i = 0, 1, 2 ✓ */ }

Fix checklist: draw the first and last iteration explicitly. Does the loop body execute the right number of times? Does the last iteration access a valid index?

Defensive technique: replace magic numbers with arr.length, str.length, etc. Use meaningful variable names like lastIdx or endExclusive to communicate intent.

An infinite loop runs forever because the termination condition is never reached. This freezes the browser/process and causes high CPU usage. The loop variable isn't being modified, or the condition is always true.

Add a counter with a max limit, use console.log to print the loop variable each iteration, or use DevTools debugger to pause and step through the loop.

In browser: DevTools → Sources → Pause button. In Node.js: use --inspect flag with Chrome DevTools.

// Infinite loop: loop variable not changing
let i = 0;
while (i < 10) {
  console.log(i); // forgot i++, loops forever!
}
// Fix: ensure termination condition advances
while (i < 10) { console.log(i); i++; }

// Infinite loop: wrong comparison
for (let i = 10; i > 0; i++) // bug: i++ grows forever
  console.log(i);
// Fix:
for (let i = 10; i > 0; i--) // decrement toward 0
  console.log(i);

// Safety counter for debugging
let guard = 0;
while (condition) {
  if (++guard > 10000) { console.error('Infinite loop!'); break; }
  // ...loop body
}

Always verify: is the loop variable moving toward the termination condition? Each iteration MUST change state in a way that eventually makes the condition false.

for...of and .forEach() cannot infinite loop (they naturally terminate). while and for loops with custom conditions are the most common culprits.

This TypeError means you're trying to call something that isn't a callable function. The variable exists but holds undefined, null, a string, a number, or a plain object — not a function.

Common causes: typo in method name, calling a property instead of a method, accessing the wrong variable, or a function that returns undefined being chained.

Use typeof to verify before calling, check the variable value with console.log, and verify the API you're using is correct.

// Common causes:
const obj = { greet: 'hello' };
obj.greet();          // TypeError: obj.greet is not a function (it's a string!)

const arr = [1, 2, 3];
arr.find(x => x > 1);
arr.finds(x => x > 1); // TypeError: arr.finds is not a function (typo!)

// Fix: verify with typeof
if (typeof fn === 'function') fn();

// Check method existence
if (arr.customMethod) arr.customMethod();

// Check chain return value
const result = getUser(); // maybe returns undefined!
result?.process();        // safe with optional chaining

Debug immediately: console.log the variable and its type before the failing line. typeof x reveals its type; compare to what you expected.

Prototype chain: calling Array methods on a non-array (e.g., an array-like object) throws this error. Use Array.from() or Array.isArray() to validate first.

Async/await bugs are usually subtle: missing await (returns Promise instead of value), forgotten async keyword (await in non-async function), not handling rejections (silent failures), or incorrect error propagation.

Add console.log before and after await to trace execution flow. Wrap in try/catch to surface errors that would otherwise be silently swallowed.

Always verify that async functions are properly awaited by their callers — calling async fn() without await gives you a Promise object, not the resolved value.

// Bug 1: missing await
async function getUser() {
  const data = fetchData();           // Promise, not data!
  console.log(data.name);            // undefined
}
// Fix:
async function getUser() {
  const data = await fetchData();     // resolved value
  console.log(data.name);            // correct!
}

// Bug 2: missing async keyword
function process() {
  const result = await compute(); // SyntaxError!
}
// Fix: add async
async function process() {
  const result = await compute();
}

// Bug 3: unhandled rejection
async function run() {
  await riskyOp();  // if this throws, error is silent!
}
// Fix:
async function run() {
  try { await riskyOp(); }
  catch(e) { console.error('Failed:', e); }
}

Unhandled Promise rejection detection: add window.addEventListener('unhandledrejection', e => console.error(e)) globally to catch missed Promise errors.

async/await is syntactic sugar over Promises. When debugging, you can rewrite as .then().catch() chains to understand the exact async flow.

Scope bugs occur when a variable is not accessible where you expect, or when a variable in an outer scope is accidentally modified. Most are caused by using var (function scope) when you need let/const (block scope).

Classic: closures in loops using var — all callbacks share the same variable reference. Using let creates a new binding per iteration.

Use strict mode ('use strict') to catch undeclared variable usage. Use const by default, let only when reassignment is needed, avoid var entirely.

// Var hoisting / loop bug
for (var i = 0; i < 3; i++)
  setTimeout(() => console.log(i), 100); // prints 3, 3, 3
// Fix: let creates a new i per iteration
for (let i = 0; i < 3; i++)
  setTimeout(() => console.log(i), 100); // prints 0, 1, 2

// Accidental global variable (in non-strict mode)
function process() {
  result = 42; // forgot 'const'! Creates global variable!
}
// Fix:
function process() {
  const result = 42;
}

// Variable shadowing confusion
const x = 10;
function fn() {
  const x = 20; // shadows outer x, doesn't modify it
  return x;     // returns 20
}

const by default: JavaScript's const and let are block-scoped. If let vs const causes confusion, console.log the variable's value at declaration and at usage points.

Hoisting: var declarations are hoisted to the top of their function scope (but NOT initialized). Accessing before assignment gives undefined, not a ReferenceError.

JavaScript's implicit type coercion converts values to compatible types during operations. This creates surprising behavior when comparing, concatenating, or doing arithmetic with mixed types.

Loose equality (==) performs coercion: 0 == '' is true, null == undefined is true. Strict equality (===) never coerces — use it by default.

The + operator is especially tricky: '5' + 3 = '53' (string concatenation), but '5' - 3 = 2 (numeric subtraction). Use parseInt/parseFloat to be explicit about numeric conversion.

// Coercion surprises
console.log(0 == '');         // true (both falsy, coerced)
console.log(0 == '0');        // true (string '0' coerces to 0)
console.log('' == '0');       // false (!)
console.log(null == undefined); // true (special case)

// Arithmetic coercion
console.log('5' + 3);    // '53' (string concat)
console.log('5' - 3);    // 2    (numeric)
console.log(+'5');        // 5    (unary + coerces to number)
console.log(!!'');        // false (double negation to boolean)

// Fix: always use ===
console.log(0 === '');   // false (correct)
console.log(0 === 0);    // true  (correct)

Always use === for comparisons. ESLint rule eqeqeq enforces this automatically. The only valid use of == is x == null to check for both null AND undefined.

Boolean coercion: empty string, 0, null, undefined, NaN, and false are falsy. Everything else (including empty array [] and empty object {}) is truthy.

In JavaScript, every function that doesn't explicitly return a value returns undefined. This is a silent bug — no error is thrown, but callers receive undefined where they expected a value.

Most dangerous in functions with conditional logic, where one code path returns normally but another path falls through without a return statement.

Functions used for side effects are fine without return. But any function whose return value is used by a caller MUST return a value on every code path.

// Bug: conditional return missing the 'else' case
function getDiscount(user) {
  if (user.isPremium) return 0.20;
  // else: falls through, returns undefined!
}
const discount = getDiscount(regularUser);
const price = 100 * (1 - discount); // 100 * (1 - undefined) = NaN!

// Fix: return on all paths
function getDiscount(user) {
  return user.isPremium ? 0.20 : 0.05;
}

// Arrow function without curly braces returns implicitly:
const add = (a, b) => a + b;    // implicit return
const broken = (a, b) => { a + b; } // no return! returns undefined

Arrow function pitfall: braces {} require an explicit return. Without braces, the expression is returned implicitly. () => { return x; } vs () => x.

Static analysis tools (ESLint rule: consistent-return) catch missing returns in functions that sometimes return a value. TypeScript will error on missing returns in typed functions.

JavaScript has many operators with similar appearance but very different behavior. The most dangerous wrong-operator bugs are often syntactically valid, so the engine doesn't catch them — the logic is just wrong.

Assignment (=) vs equality (===) is the most common; using = inside an if condition always evaluates to truthy (unless assigning 0/null/undefined). Strict equality checks both value AND type.

Logical &&/|| are short-circuit operators. Nullish coalescing (??) is often more appropriate than || when checking for null/undefined specifically.

// Assignment in condition (usually a bug)
if (x = 5) { }    // always true (assigns 5 to x)
if (x === 5) { }  // comparison (correct)

// || vs ?? for defaults
const volume = userSetting || 50; // bug if userSetting is 0!
const volume = userSetting ?? 50; // correct: only for null/undefined

// & vs &&
if (a & b) { }   // bitwise AND (treats as integers)
if (a && b) { }  // logical AND (short-circuits)

// String + vs number +
'5' + 3 = '53';  // string concat (wrong if expecting sum)
+('5') + 3 = 8;  // force numeric (correct)

Enable 'no-cond-assign' ESLint rule to automatically catch accidental assignments inside conditions. Most linters report this as a warning.

Nullish coalescing (??) only treats null/undefined as missing values. Logical OR (||) also treats 0, '', and false as missing — which causes bugs when those are intentional values.

Objects and arrays are passed by REFERENCE in JavaScript. Modifying them inside a function changes the original outside the function. This creates action-at-a-distance bugs that are hard to trace.

The fix is immutability: create a copy before modifying. Shallow copy with spread ({...obj} or [...arr]) works for one level. For nested objects, use structuredClone() or deep copy libraries.

Mutation bugs are especially severe in React (don't mutate state directly) and Redux (always return new objects from reducers).

// Mutation bug
function addItem(arr, item) {
  arr.push(item);  // MUTATES original array!
  return arr;
}
const original = [1, 2, 3];
addItem(original, 4);
console.log(original); // [1, 2, 3, 4] — original changed!

// Fix: return a new array
function addItem(arr, item) { return [...arr, item]; }

// Object mutation bug
function setDefault(config) {
  config.timeout = config.timeout || 3000; // modifies original!
}
// Fix:
function setDefault(config) {
  return { ...config, timeout: config.timeout ?? 3000 };
}

// Deep clone for nested structures
const deepCopy = structuredClone(original); // ES2022, handles nested

Spread creates a shallow copy: for nested objects, inner objects are still shared. Use structuredClone() or JSON.parse(JSON.stringify(obj)) for deep clones.

Immutability patterns: const prevents reassignment but NOT mutation of object contents. Object.freeze() prevents property mutation (shallow only).

Callbacks execute when their triggering condition completes (timer, event, IO). If you mix synchronous and asynchronous code expecting top-to-bottom order, you will get surprising results.

setTimeout with delay 0 still runs AFTER all synchronous code completes — it's placed in the task queue, not executed immediately.

Solution: use Promises and async/await to express sequential async operations clearly without callback order confusion.

// Unexpected order with callbacks
console.log('1');
setTimeout(() => console.log('2'), 0); // queued, runs last
console.log('3');
// Output: 1, 3, 2 (not 1, 2, 3)

// Fix: use async/await for sequential operations
async function run() {
  console.log('1');
  await someAsyncTask(); // properly awaited
  console.log('2');      // runs after task completes
  console.log('3');
}

Event loop rule: setTimeout callback goes to the task queue. It only executes after the current call stack is empty and all microtasks (Promises) are resolved.

For coordinating multiple async operations, use Promise.all() (parallel) or sequential await chains to enforce the intended order.

Memory leaks occur when objects are no longer needed but still referenced, preventing garbage collection. Common sources: global variables, forgotten event listeners, closures holding references, and detached DOM nodes.

Use browser DevTools Memory tab: take heap snapshots, compare before/after, look for growing allocations. Record heap allocation timeline to spot repeated growth.

Prevention is key: clean up event listeners, use WeakMap/WeakSet for non-essential references, avoid accidental global variables.

// Common leak: event listener not removed
function addListener() {
  const data = new Array(1000000).fill('leak');
  document.addEventListener('click', () => console.log(data)); // holds data!
}
// Fix: remove listener when done
const handler = () => console.log(data);
document.addEventListener('click', handler);
// later:
document.removeEventListener('click', handler);

WeakRef and FinalizationRegistry (ES2021) allow holding weak references that don't prevent garbage collection — useful for caches.

Node.js: use --inspect flag with Chrome DevTools or the heapdump module to take heap snapshots and identify growing memory.

A race condition occurs when the behavior of code depends on the timing/order of asynchronous operations, yielding different results on different runs. Classic example: read-modify-write operations on shared state without synchronization.

In JavaScript's single-threaded model, race conditions happen with async operations — two async calls both read a value, modify it, and write back, with the second overwriting the first's change.

Prevention: ensure async operations that depend on each other are properly sequenced with await, or use atomic operations/optimistic locking patterns.

// Race condition: concurrent increments
let counter = 0;
async function increment() {
  const current = counter;        // both reads happen before either write
  await delay(10);
  counter = current + 1;          // second write overwrites first!
}
increment(); increment();
// counter ends up as 1, not 2

// Fix: use sequential operations
async function safeIncrement() {
  await lock.acquire();           // ensure exclusive access
  counter++;
  lock.release();
}

In JS single-threaded environments: race conditions occur between async operations (await points). Within a synchronous block, no race conditions are possible.

For shared resources across Web Workers, use SharedArrayBuffer with Atomics for proper synchronization.

This is a stack overflow — infinite or excessively deep recursion. Every function call adds a frame to the call stack; when the limit is exceeded, this error is thrown.

Common causes: missing base case in recursion, mutual recursion without termination, circular data structures, or excessively deep data (deeply nested JSON).

Fix: verify base case is correct and reachable, convert deep recursion to iteration, or use trampolining for tail-recursive functions.

// Stack overflow: missing base case
function factorial(n) {
  return n * factorial(n - 1); // never stops! (missing if n === 0 return 1)
}

// Fix: add base case
function factorial(n) {
  if (n <= 1) return 1;        // base case
  return n * factorial(n - 1);
}

// Iterative: no stack overflow risk
function factorialIter(n) {
  let result = 1;
  for (let i = 2; i <= n; i++) result *= i;
  return result;
}

Debug tip: print n at the start of your recursive function — if it's growing instead of shrinking toward the base case, you've found the bug.

Node.js default stack depth is ~15,000 frames. Use --stack-size=65536 flag to increase it temporarily for debugging.

Attempting to access a property on undefined (or null) throws this error. Common when: data is not yet loaded (async), an array/object is empty, a function returns undefined unexpectedly, or incorrect chaining.

Optional chaining (?.) prevents the error by short-circuiting to undefined instead of throwing. For older code, use explicit null checks.

Destructuring with defaults is another clean prevention pattern: const { name = 'default' } = user ?? {}.

// Bug: accessing property of undefined
const user = null;
console.log(user.name); // TypeError!

// Fix 1: explicit check
if (user) console.log(user.name);

// Fix 2: optional chaining
console.log(user?.name);        // undefined (no throw)
console.log(user?.address?.city); // safe deep access

// Fix 3: nullish coalescing
const name = user?.name ?? 'Anonymous';

Optional chaining (?.): if the left side is null/undefined, short-circuits to undefined instead of throwing. Available in ES2020+.

Always validate API responses — network data can have missing fields. Use schema validation (Zod, Joi) for robust data validation at boundaries.

Beyond console.log, JavaScript provides console.table (for arrays/objects), console.group/groupEnd (for hierarchical output), console.time/timeEnd (for performance measurement), and console.trace (for call stack output).

console.error and console.warn provide semantic meaning and different visual styling in DevTools. console.assert is useful for quick assertions during debugging.

For production code, remove or disable console statements — they impact performance and expose internal details.

// Table output for arrays
console.table([{name:'Alice',age:30},{name:'Bob',age:25}]);

// Timing code blocks
console.time('sort');
arr.sort(); // time this operation
console.timeEnd('sort');  // prints: sort: 2.341ms

// Call stack trace
function deep() { console.trace('called from:'); }

// Conditional assertion
console.assert(arr.length > 0, 'Array should not be empty!');

// Group related logs
console.group('User data');
console.log('Name:', user.name);
console.log('Age:', user.age);
console.groupEnd();

console.time/timeEnd pairs measure exact execution time of code blocks — far more precise than Date.now() calls for micro-benchmarks.

DevTools debugger with breakpoints is superior to console.log for complex bugs — inspect variable state at any execution point without code modification.

Unhandled Promise rejections silently swallow errors in old Node.js versions and some browsers. Always attach .catch() or use try/catch with async/await. Check that all Promise chains are properly terminated.

In Node.js, listen for the 'unhandledRejection' event. In browsers, use window.addEventListener('unhandledrejection', ...) to catch missed Promise errors globally.

The most common mistake: async function called without await — the returned Promise is ignored and rejections disappear.

// Silent failure
fetch('/api/data')
  .then(r => r.json())
  .then(handleData); // no .catch()!

// Fix: always handle rejections
fetch('/api/data')
  .then(r => r.json())
  .then(handleData)
  .catch(err => console.error('Fetch failed:', err));

// Or with async/await
async function loadData() {
  try {
    const r = await fetch('/api/data');
    return await r.json();
  } catch (err) {
    console.error('Failed:', err);
    throw err; // re-throw after logging
  }
}

Never lose errors: always terminate Promise chains with .catch(). Consider a global catch for unhandled rejections in production for logging.

Promise.allSettled() (ES2020) is preferable to Promise.all() when you want all results regardless of failures — won't throw on first rejection.

Performance bugs are often algorithmic (O(n²) instead of O(n log n)), structural (re-rendering entire UI for small changes), or implementation-level (inefficient DOM queries, blocking operations on main thread).

Profile first: use browser DevTools Performance tab to identify the slowest operations. CPU profiles show which functions take the most time.

Common fixes: memoize expensive calculations, debounce/throttle event handlers, use virtual DOM or incremental rendering, replace nested loops with hash maps.

// Performance bug: O(n²) intersection
function intersect(a, b) {
  return a.filter(x => b.includes(x)); // b.includes is O(n) → total O(n²)
}

// Fix: O(n) with Set
function intersectFast(a, b) {
  const setB = new Set(b);     // O(n) to build
  return a.filter(x => setB.has(x)); // O(1) lookup → total O(n)
}

// Debounce: limit how often a function runs
function debounce(fn, delay) {
  let timer;
  return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); };
}

Measure before and after any optimization — confirm the improvement is real and significant before committing the change.

React performance: use React.memo, useMemo, useCallback to prevent unnecessary re-renders. Check with React DevTools Profiler to see which components render unnecessarily.

The value of this depends on HOW a function is called, not where it's defined. Arrow functions capture this from their enclosing scope at definition time — they never have their own this.

Common bug: passing a method as a callback loses its original this context. Fix with .bind(), arrow function wrapper, or storing a reference.

Use strict mode to catch accidental global this usage — in strict mode, standalone function calls have this === undefined instead of the global object.

class Timer {
  constructor() { this.count = 0; }
  // Bug: this.count is undefined in callback
  start() { setInterval(function() { this.count++; }, 1000); }
  // Fix 1: arrow function (lexical this)
  startFixed() { setInterval(() => { this.count++; }, 1000); }
  // Fix 2: bind
  startBind() { setInterval(function() { this.count++; }.bind(this), 1000); }
}

// Also: method detachment
const obj = { value: 42, get() { return this.value; } };
const fn = obj.get; // detached!
fn(); // undefined (this = global/undefined in strict)

Arrow functions are the modern solution — they capture this lexically. Avoid using regular functions as callbacks on class methods.

Use console.log(this) at the start of any suspected function to immediately see what this is at that point — the fastest debugging technique.

Circular references occur when object A references B, and B references A (directly or through a chain). They cause stack overflows in recursive algorithms, errors in JSON.stringify, and memory leaks in manual reference counting systems.

JSON.stringify throws "Converting circular structure to JSON." WeakMap-based serializers or replacer functions can handle this.

In recursive algorithms, track visited nodes — if you encounter a visited node, you've found a cycle.

// Circular reference
const a = {};
const b = { a };
a.b = b; // circular!

// JSON.stringify will throw
// JSON.stringify(a); // TypeError: circular structure

// Fix: safe serializer with replacer
function safeJSON(obj) {
  const seen = new WeakSet();
  return JSON.stringify(obj, (key, value) => {
    if (typeof value === 'object' && value !== null) {
      if (seen.has(value)) return '[Circular]';
      seen.add(value);
    }
    return value;
  });
}

// Cycle detection in graphs
function hasCycle(graph, start) {
  const visiting = new Set(), visited = new Set();
  function dfs(node) {
    if (visiting.has(node)) return true; // cycle found
    if (visited.has(node)) return false;
    visiting.add(node);
    for (const nb of graph[node] || []) if (dfs(nb)) return true;
    visiting.delete(node); visited.add(node);
    return false;
  }
  return dfs(start);
}

Three-color DFS for cycle detection: white (unvisited), gray (in current path), black (fully visited). Gray → gray edge means a cycle.

WeakMap/WeakSet are ideal for tracking visited objects during serialization — they don't prevent garbage collection of objects that are otherwise unreachable.