Debugging and Systematic Troubleshooting Questions
Diagnosing defects methodically: reproducing failures, forming and testing hypotheses, reading stack traces and logs, bisecting changes, and reasoning about error handling and edge cases. Covers a disciplined root-cause approach that applies from local bugs to production issues, distinct from embedded hardware-level debugging. A universally probed engineering-craft skill.
Find and fix the bug in this JavaScript async function, where a missing await causes unpredictable ordering and errors:
async function processItems(items, processor) {
items.forEach(async item => {
await processor(item);
});
console.log('done');
}
Explain the root cause and provide a corrected version that guarantees 'done' prints only after every item has been processed.
Sample Answer
Direct answer
Array.prototype.forEach does not await its callback: it fires each async callback and moves on immediately, so console.log('done') runs before any of the await processor(item) calls have resolved. The fix is to replace forEach with a construct that actually waits, either a for...of loop with await inside it (sequential) or Promise.all over items.map(...) (concurrent).
Structured elaboration
forEach was designed before async/await existed and its callback's return value, including a Promise, is simply discarded. Passing an async function as the callback doesn't change this: each invocation still returns a Promise that forEach never looks at, so forEach itself completes synchronously (having merely started every item, not finished any of them) the instant it has called the callback once per array element. The console.log('done') line after the forEach call then runs immediately, while the async work is still in flight in the background.
Worked example
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// Fixed, distinct per-item delays (not Math.random()) so the interleaving below
// is deterministic and reproduces identically on every run, not just illustrative.
const delays = { 1: 20, 2: 10, 3: 30 };
// BUGGY
async function processItemBuggy(x) { await sleep(delays[x]); console.log(' processed', x, '(buggy)'); }
async function processBuggy(items) {
items.forEach(async item => { await processItemBuggy(item); });
console.log('done (buggy)');
}
// FIXED: sequential
async function processItemFixed(x) { await sleep(delays[x]); console.log(' processed', x, '(fixed)'); }
async function processFixed(items) {
for (const item of items) { await processItemFixed(item); }
console.log('done (fixed)');
}
(async () => {
console.log('--- buggy run ---');
await processBuggy([1, 2, 3]);
await sleep(100); // let the buggy run's background work finish before the next section starts
console.log('--- fixed run (sequential for-of) ---');
await processFixed([1, 2, 3]);
})();
Executed output (buggy items finish asynchronously in the background; with the fixed per-item delays above, item 2 always resolves first, then item 1, then item 3, after done has already printed):
--- buggy run ---
done (buggy)
processed 2 (buggy)
processed 1 (buggy)
processed 3 (buggy)
--- fixed run (sequential for-of) ---
processed 1 (fixed)
processed 2 (fixed)
processed 3 (fixed)
done (fixed)
done (buggy) prints immediately, before any processed line: forEach fired all three async callbacks and moved on without waiting for any of them. The processed (buggy) lines do eventually print, once their fixed delays resolve, in delay order (2, then 1, then 3, not the input order), rather than before done. The for...of version correctly prints all three processed lines, strictly in input order, before done.
Trade-offs and pitfalls
There are two valid fixes with different semantics, and picking the wrong one is itself a common mistake: a for...of loop processes items strictly one at a time (useful when order matters or you must not overwhelm a downstream dependency with concurrent calls), while await Promise.all(items.map(item => processor(item))) processes all items concurrently and finishes as soon as the slowest one does (faster, but only safe if the operations are independent and the downstream system can handle concurrent load). Silently reaching for forEach out of habit, rather than deliberately choosing sequential versus concurrent semantics, is exactly how this bug class gets reintroduced even by developers who already know the rule.
Given a function that sometimes throws an exception deep inside a library you cannot modify, explain how you would instrument the codebase to capture a stack trace and the relevant contextual variables without changing the library's own code. Describe approaches for at least two of Python, Java, or Node.js.
Sample Answer
Direct answer
You can capture a stack trace and the relevant local state without touching the library's source by instrumenting at the BOUNDARY where you call into it: wrap the call site in a try/catch (or its language equivalent) that re-raises after logging, install a global/uncaught-exception hook that fires regardless of where the exception originates, or attach a debugger/tracer that breaks on the exception type without modifying any code at all.
Structured elaboration
Three complementary approaches, from least to most invasive:
- Wrap the call site, not the library. Since you control the code that CALLS into the library, even if you can't modify the library itself, catching the exception at your own call site and logging the full stack trace, the exception type/message, and any locally-relevant variables (the arguments you passed in, the current request context) before re-raising gives you a permanent, low-overhead capture point. In Python:
try: library_call(...) except Exception: logger.exception("context: %s", relevant_vars); raise. In Java: a try/catch around the call that logs viae.printStackTrace()or a structured logger before rethrowing. In Node.js: wrapping in a try/catch for synchronous calls, or.catch()on the returned Promise for async ones. - Install a global uncaught-exception/unhandled-rejection hook. This catches cases where the exception surfaces somewhere you didn't anticipate wrapping: Python's
sys.excepthook, Java'sThread.setDefaultUncaughtExceptionHandler, Node'sprocess.on('uncaughtException', ...)andprocess.on('unhandledRejection', ...). These fire regardless of exactly where inside the library the exception originated, at the cost of being a last-resort catch-all rather than a precisely-scoped one. - Attach a debugger or tracer without changing code, when you need MORE than a stack trace, such as full local variable state at the moment of the throw. Most debuggers support "break on exception" (Python's
pdbwithpython -m pdb, orimport pdb; pdb.set_trace()combined with a signal, or a conditional breakpoint set on the exception type in a full IDE debugger; Java's debugger supports exception breakpoints directly in most IDEs) which pauses execution at the exact throw site, inside the library's own code, letting you inspect the full call stack and locals without ever editing the library.
Worked example
A third-party HTTP client library occasionally raises an unlabeled ConnectionError with no useful message, and you need to know what request triggered it. Wrapping the call site: try: response = third_party_client.get(url, timeout=5) except ConnectionError: logger.exception("third_party_client failed for url=%s, timeout=%s", url, 5); raise. This doesn't change a single line of the library, but every future occurrence now logs the exact URL and timeout that triggered it, alongside the library's own stack trace, immediately giving you the context needed to distinguish "this URL is consistently unreachable" from "this only fails under a specific timeout value."
Trade-offs and pitfalls
A global uncaught-exception hook is a safety net, not a substitute for a targeted wrap at the call site: it tells you SOMETHING failed somewhere, but without the call-site context (what arguments were in play, what business operation was in progress) it's often not enough to actually diagnose the issue, only to know it happened. The debugger approach is the most informative but requires either a reproducible local trigger or an interactive session, so it's best reserved for cases where the logged stack trace alone isn't enough to form a hypothesis.
Find and explain the bug in this Python function, then provide a corrected implementation:
def append_item(item, lst=[]):
lst.append(item)
return lst
# append_item(1) -> [1]
# append_item(2) -> [1, 2] # unexpected: shared across calls
Explain why the sharing happens and show the safe fix for the default argument.
Sample Answer
Direct answer
The bug is that lst=[] creates the default list once, at function-definition time, not once per call. Every call that omits the lst argument reuses the exact same list object, so items accumulate across calls that were never meant to share state. The fix is to use None as the sentinel default and create a fresh list inside the function body when no list was passed.
Structured elaboration
In Python, default argument values are evaluated exactly once, when the def statement executes, and the resulting object is bound to the function itself. A mutable default (a list, dict, or set) is therefore the SAME object across every call that relies on the default, so mutating it in one call is visible in the next. This is a well-known Python pitfall precisely because the code reads as if a fresh list is created per call, which is the intuitive (and correct) behavior for immutable defaults like None, 0, or "", but not for mutable ones.
Worked example
def append_item_buggy(item, lst=[]):
lst.append(item)
return lst
def append_item_fixed(item, lst=None):
if lst is None:
lst = []
lst.append(item)
return lst
print("BUGGY:")
print(append_item_buggy(1))
print(append_item_buggy(2))
print("FIXED:")
print(append_item_fixed(1))
print(append_item_fixed(2))
Executed output:
BUGGY:
[1]
[1, 2]
FIXED:
[1]
[2]
The buggy version's second call returns [1, 2] instead of the expected [2], because it's still appending to the exact same list object created back when the function was defined. The fixed version creates a brand-new list inside the function body on every call where the caller didn't explicitly pass one, so each call starts clean.
Trade-offs and pitfalls
This bug is especially dangerous because it's silent and cumulative: a function like this can appear to work correctly in isolated tests (each test creates its own scenario and might not notice the shared state) and only manifest as data mysteriously growing or leaking between unrelated calls once the function is used repeatedly in a long-running process, which is exactly the kind of "works in tests, fails in production" gap this class of bug produces. The general rule: never use a mutable object (list, dict, set, or a custom mutable class instance) as a default argument value; use None and construct the mutable object inside the function body instead.
What signals in your logs and metrics would push you to roll back a deployment versus continue debugging the currently deployed version of a service? Give a short rubric you use in production, plus a brief example of applying it.
Sample Answer
Direct answer
Roll back when the signals show the blast radius is growing or user-facing impact is severe and the fix isn't yet proven; keep debugging in place when the signals are stable or narrowing and you have a low-risk path to more evidence. The rubric is about trend and confidence, not just the current error rate: a flat 2% error rate you understand is safer to sit with briefly than a 0.5% rate that's climbing and whose cause is still unknown.
Structured elaboration
Concrete signals that push toward rollback:
- Error rate or latency is still climbing, not plateaued, meaning the blast radius is actively growing while you investigate.
- The failure touches a critical path (payments, auth, data writes) where continued exposure risks data integrity or revenue, not just degraded UX.
- You cannot yet state a specific hypothesis for the cause. If you don't know what's wrong, you can't bound how bad it might get, and every minute of uncertainty is a minute of unbounded risk.
- The most recent deploy is a plausible cause and rolling it back is cheap (a well-tested, low-risk rollback path exists). When rollback is cheap and the cost of being wrong is high, the asymmetry favors rolling back even before the cause is confirmed.
Signals that support continuing to debug in place:
- The metric is flat or already recovering on its own (e.g., a transient dependency blip that's clearing), meaning the risk is bounded and shrinking.
- You have a specific, testable hypothesis and the evidence to confirm or reject it is minutes away, not hours.
- The failure is isolated to a non-critical path or a small, known user segment, so continued exposure has a low, well-understood ceiling.
- Rolling back has its own real cost (loses a needed migration, reintroduces a different known bug, or the rollback path itself is unproven), making rollback riskier than the current state.
Two more factors that belong in the rubric alongside the raw signals: your technical confidence in a proposed quick patch, and your monitoring capability while you wait. A patch you are highly confident in, that you can deploy and verify within minutes, tips the balance toward fixing forward even at a moderate error rate; a patch you're guessing at does not, no matter how appealing "just ship the fix" feels under pressure. Separately, if your monitoring can only tell you the aggregate error rate but not WHO is affected or WHY, you are debugging half-blind, and that itself is a reason to prefer the safer, well-understood state (rollback) over continuing to poke at a system you can't fully observe.
Worked example
A rubric applied in production: after a deploy, error rate on the checkout endpoint rises from 0.1% to 1.5% and holds flat for four minutes while you check logs. The rate is not climbing further, the failures are concentrated in one specific edge case (carts with a discount code applied), and a log line points directly at a null-handling bug in the new discount logic. Decision: continue debugging, because the blast radius is bounded, isolated, and you have a specific hypothesis you can confirm in the next two minutes. Contrast: if that same 1.5% had climbed to 6% over those four minutes with no clear pattern in the failing requests, the correct call flips to immediate rollback, because the trend is the dominant signal, not the absolute number.
Trade-offs and pitfalls
The rubric fails when applied to a single snapshot instead of a trend: a rate that looks acceptable in isolation can be five minutes from becoming a major incident, or a rate that looks alarming can already be resolving itself. The discipline is to always ask "is this getting better, worse, or staying the same" before deciding, and to treat "I don't have a hypothesis yet" as itself a strong vote toward rollback, since it means you cannot bound the risk of waiting.
Explain rubber-duck debugging and describe how you would use it collaboratively to help a teammate find a bug they are stuck on. Include when it is appropriate to escalate to active pair programming versus continuing to guide them through the technique.
Sample Answer
Direct answer
Rubber-duck debugging is the practice of explaining your code or problem, line by line, out loud, to an inanimate object (or any passive listener), on the theory that the act of ARTICULATING your assumptions forces you to notice the gap between what you believe the code does and what it actually does; used collaboratively, you become the "duck," a real listener whose job is to stay quiet and let the other person talk through it, only interjecting with clarifying questions, not answers.
Structured elaboration
Why it works: most debugging time is lost not to hard problems but to an unexamined assumption ("of course X is true here") that the explainer has never actually had to state out loud; the discipline of narrating each step forces exactly that statement, and the mismatch often becomes obvious the moment it's spoken, even before the listener says anything at all.
How to use it collaboratively to help a stuck teammate:
- Sit with them and ask them to explain the problem from the beginning, including what they expect to happen and what's actually happening, as if you know nothing about the code (even if you do); resist the urge to jump in with your own theory immediately, since the goal is for THEM to find the gap through their own narration, not for you to hand them the answer.
- Ask clarifying, not leading, questions when something seems glossed over: "what does this variable contain at this point?" rather than "isn't this variable actually null here?"; a clarifying question preserves the self-discovery effect, while a leading question short-circuits it, in effect just answering the question yourself, in a slightly slower way.
- Notice when they pause or hesitate on a specific line, since that hesitation is itself a signal, often the exact spot where their explanation doesn't fully hold together, even if THEY haven't consciously noticed it yet; gently returning to that point ("can you say more about what happens right there?") is more effective than moving on.
- Let silence do some of the work. Resisting the urge to fill every pause with your own guess gives them room to keep narrating and often arrive at the insight themselves, which is both faster (no back-and-forth debugging your OWN, potentially wrong, theory) and more valuable for their own learning than being handed the answer.
When to escalate to active pair programming instead of continuing to guide:
- When the explanation reveals a gap in UNDERSTANDING (not just an overlooked line) that narration alone won't resolve, e.g., a genuine misunderstanding of how a language feature or library behaves, where providing the missing knowledge is more useful than more questions.
- When you notice, through their narration, something concrete they clearly haven't seen (a specific line, a specific value) and pointing it out directly is faster and kinder than continuing an extended Socratic process once the value of self-discovery has been exhausted for this specific bug.
- When time pressure genuinely doesn't allow for the (often somewhat slower) self-discovery process, in which case switching to active pairing, working the problem together directly, is the pragmatic choice, with an explicit acknowledgment that you're switching modes for a good reason, not simply losing patience.
Worked example
A teammate stuck on why a function returns stale data: walking them through explaining the code line by line, they narrate "and then this checks the cache... and returns it if it's still valid... I wanted a sixty-second expiry, so I set the value to sixty thousand..." and pause, mid-sentence, on "sixty thousand," suddenly noticing they'd been assuming the codebase's convention was MILLISECONDS (so they multiplied their intended sixty seconds by 1000 before storing it), when the surrounding code that actually reads this field treats it as SECONDS directly, with no conversion; storing 60000 where the code expects 60 means the real expiry is 60000 seconds (about 16.7 hours), roughly 1000x longer than the sixty seconds they intended, which is exactly why the data was going stale. They found this entirely through their own narration; the only input needed was staying quiet and letting them keep talking through it.
Trade-offs and pitfalls
Rubber-duck debugging (with a real, silent listener) works best for a SPECIFIC kind of stuck-ness, an unexamined assumption, not for a genuine knowledge gap or a problem requiring information the person simply doesn't have; recognizing which kind of "stuck" you're looking at, and switching to direct guidance or active pairing when it's the latter, is what keeps the technique from wasting time on a problem it was never suited to solve.
Unlock Full Question Bank
Get access to all 25 Debugging and Systematic Troubleshooting interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.