Debugging Like a Detective
Real-world debugging strategies from production environments—from fixing payment failures to resolving blockchain transaction issues.
Debugging Like a Detective
In five years of production work across fintech, Web3, and enterprise systems, I’ve debugged everything from failed international payments to stuck blockchain transactions. The stakes are high—when real money is involved, bugs aren’t just annoying; they’re costly.
Here’s the systematic approach that has served me across thousands of production issues.
The Detective Mindset
Effective debugging is investigative work:
- Gather evidence before theorizing - Don’t guess; observe
- Question every assumption - “It works on my machine” is not enough
- Follow the data trail - Logs, network requests, state changes
- Document your findings - You’ll need them for the post-mortem
The Debugging Process
1. Reproduce the Bug Reliably
Can you make it happen on demand?
In production environments, intermittent bugs are the hardest to fix. I always start by creating a minimal reproduction:
Real Example from MonieWorld:
Bug Report: "Payment fails sometimes when using Google Pay"
Initial Investigation:
- Works fine in dev environment ✅
- Fails ~20% of the time in production ❌
Reproduction Steps (after investigation):
1. User must have multiple saved payment methods
2. Google Pay must be selected AFTER another method was pre-selected
3. Happens only when total amount > £1000
Root Cause: Race condition in payment method selection state
Creating Minimal Reproductions:
- Strip away everything unrelated
- Identify exact conditions (browser, network speed, data state)
- Check if it’s timing-dependent
- Verify it happens for other users too
2. Use the Binary Search Approach
When a bug appears in new code, narrow down the cause by bisecting your changes:
// Instead of checking everything at once
async function processPayment(data: PaymentData) {
console.log('1. Input received:', data);
const validated = await validatePayment(data);
console.log('2. After validation:', validated);
const converted = convertCurrency(validated);
console.log('3. After conversion:', converted);
const result = await submitToProvider(converted);
console.log('4. Provider response:', result);
return result;
}
Each checkpoint cuts your search space in half. When you find where the data breaks, you’ve found your bug.
3. Check Your Assumptions
Most bugs hide in assumptions we don’t realize we’re making.
Common Assumption Errors:
// ❌ Assumes API always returns data
function displayRecipients(response) {
return response.data.map((r) => <RecipientCard {...r} />);
// Breaks when response.data is null or undefined
}
// ✅ Defensive coding
function displayRecipients(response) {
const recipients = response?.data ?? [];
if (recipients.length === 0) {
return <EmptyState />;
}
return recipients.map((r) => <RecipientCard {...r} />);
}
// ❌ Assumes date is in expected format
const date = new Date(transaction.createdAt);
// Breaks if createdAt is null, undefined, or invalid format
// ✅ Validate before using
function parseTransactionDate(dateString: string | null): Date | null {
if (!dateString) return null;
const parsed = new Date(dateString);
return isNaN(parsed.getTime()) ? null : parsed;
}
4. Master Your Debugging Tools
Browser DevTools (Beyond console.log)
// Structured logging
console.table(recipients); // Formats arrays of objects beautifully
// Performance timing
console.time('API Call');
await fetchRecipients();
console.timeEnd('API Call'); // Logs elapsed time
// Conditional logging
console.assert(amount > 0, 'Amount must be positive, got:', amount);
// Stack trace
console.trace('How did we get here?');
Network Tab for API Issues
When debugging payment or blockchain transaction failures:
-
Check the request:
- Are all required headers present?
- Is the body correctly formatted?
- Is authentication included?
-
Check the response:
- What’s the status code? (200, 400, 500?)
- What error message is returned?
- Are there CORS issues?
-
Check timing:
- Is the request timing out?
- Is there a race condition?
Real Example from Web3 Debugging:
Issue: "Transaction keeps failing"
Network Tab Shows:
- Request: eth_sendTransaction
- Response: "insufficient funds for gas * price + value"
Root Cause: Not accounting for gas costs in balance check
React DevTools
Essential for React debugging:
- Inspect component props and state
- Track which components re-render
- Profile performance bottlenecks
Using Debugger Statements
function calculateFees(amount: number, rate: number) {
debugger; // Execution pauses here in DevTools
const baseFee = amount * rate;
const total = amount + baseFee;
return total;
}
When the debugger pauses:
- Inspect local variables
- Step through execution line by line
- Evaluate expressions in the console
5. Form and Test Hypotheses
Bad hypothesis: “The API is broken” Good hypothesis: “The API returns a 400 error when the recipient country code is missing, causing the payment form to show a generic error instead of a field-specific validation message”
How to Test Hypotheses:
- Add targeted logging:
function submitPayment(data: PaymentData) {
console.log('Hypothesis: Country code is missing');
console.log('Recipient data:', data.recipient);
console.log('Has country code?', Boolean(data.recipient?.country));
}
- Write a failing test:
it('should show field error when country code missing', () => {
const incompleteRecipient = { name: 'John', country: null };
render(<PaymentForm recipient={incompleteRecipient} />);
fireEvent.click(screen.getByText('Submit'));
expect(screen.getByText('Country is required')).toBeInTheDocument();
});
- Test in isolation:
// Test the function separately from the UI
describe('validateRecipient', () => {
it('rejects recipient without country', () => {
const result = validateRecipient({ name: 'John', country: null });
expect(result.errors).toContain('country');
});
});
Advanced Techniques
Git Bisect for Regression Bugs
When something breaks but you don’t know when:
# Start bisecting
git bisect start
git bisect bad # Current commit is broken
git bisect good v2.1.0 # This version worked
# Git checks out commits for you to test
# After testing each commit:
git bisect good # if it works
git bisect bad # if it's broken
# Git will eventually tell you the exact commit that introduced the bug
git bisect reset # when done
I’ve used this to track down bugs introduced weeks ago in codebases with hundreds of commits.
Rubber Duck Debugging
Explaining code line-by-line (even to an inanimate object) forces you to articulate your assumptions. I’ve caught countless bugs just by explaining the code to a colleague.
The Process:
- Start from where data enters the system
- Explain what each line does
- State what you expect to happen
- Often, you’ll hear yourself say “wait, that’s not right…”
Strategic Logging in Production
In production environments, you can’t use a debugger. Strategic logging is essential:
// Use log levels appropriately
logger.debug('Payment form rendered', { recipientId });
logger.info('Payment submitted', { amount, currency });
logger.warn('Retry attempt 2 of 3', { transactionId });
logger.error('Payment failed', { error, context });
// Include context that helps debugging
logger.error('Transaction failed', {
userId: user.id,
amount: payment.amount,
currency: payment.currency,
timestamp: Date.now(),
userAgent: req.headers['user-agent'],
// But NEVER log sensitive data like card numbers or passwords
});
Common Bug Patterns
Race Conditions
// ❌ Race condition
let paymentResult;
processPayment().then((result) => {
paymentResult = result;
});
if (paymentResult.success) {
// undefined! Promise hasn't resolved
showSuccess();
}
// ✅ Proper async handling
const paymentResult = await processPayment();
if (paymentResult.success) {
showSuccess();
}
Off-by-One Errors
// ❌ Classic mistake
for (let i = 0; i <= transactions.length; i++) {
console.log(transactions[i]); // undefined on last iteration
}
// ✅ Correct
for (let i = 0; i < transactions.length; i++) {
console.log(transactions[i]);
}
// ✅ Even better: avoid manual indexing
transactions.forEach((transaction) => {
console.log(transaction);
});
State Mutations
// ❌ Mutating state directly (React anti-pattern)
function addRecipient(newRecipient) {
recipients.push(newRecipient); // Mutates state!
setRecipients(recipients); // React won't detect the change
}
// ✅ Immutable update
function addRecipient(newRecipient) {
setRecipients((prev) => [...prev, newRecipient]);
}
Prevention is Better Than Cure
Write Testable Code
// ❌ Hard to test (tightly coupled)
function handlePaymentSubmit() {
const formData = document.getElementById('payment-form').value;
fetch('/api/payment', { body: formData });
alert('Payment submitted!');
}
// ✅ Testable (dependencies injected)
function handlePaymentSubmit(
getFormData: () => PaymentData,
submitPayment: (data: PaymentData) => Promise<Result>,
showNotification: (message: string) => void
) {
const data = getFormData();
return submitPayment(data).then(() => showNotification('Payment submitted!'));
}
Use TypeScript
Type checking catches entire classes of bugs before runtime:
interface Recipient {
id: string;
name: string;
country: string;
}
function formatRecipient(recipient: Recipient): string {
return `${recipient.name} (${recipient.country})`;
}
// TypeScript prevents these errors at compile time:
formatRecipient({ name: 'John' }); // Error: missing country
formatRecipient(null); // Error: type mismatch
At MonieWorld, TypeScript caught hundreds of potential runtime errors during development.
Leverage Testing
Well-tested code is easier to debug:
- Unit tests isolate functions for debugging
- Integration tests reveal how components interact
- E2E tests catch user-facing bugs
When a test fails, you have a reproduction case ready to debug.
The Debugging Checklist
When stuck, systematically check:
- Can I reproduce the bug reliably?
- Have I checked the obvious? (typos, missing imports, wrong variables)
- Have I verified my assumptions? (null checks, array lengths, type coercion)
- Have I checked the network tab? (API responses, status codes, timing)
- Have I checked the console? (errors, warnings, logs)
- Have I looked at the stack trace?
- Have I searched for similar issues? (Google, GitHub Issues, Stack Overflow)
- Have I taken a break and come back with fresh eyes?
Real-World Impact
Effective debugging has saved me countless hours:
- MonieWorld: Identified and fixed race condition causing 20% payment failure rate → 99.6% success rate
- Unlockd: Tracked down wallet connection issue causing 30% drop-off → reduced to 5%
- myNFT: Debugged smart contract interaction causing failed transactions → 0 failed transactions post-fix
Conclusion
Debugging is not about luck or intuition—it’s a systematic skill that improves with practice. Stay methodical, challenge your assumptions, and remember: every bug is an opportunity to understand your system more deeply.
The best debuggers aren’t the ones who fix bugs the fastest—they’re the ones who prevent them from happening in the first place.
Happy debugging! 🔍
Assumption Errors
// Don't assume
if (user.settings) {
// What if user is null?
// ...
}
// Be defensive
if (user && user.settings) {
// ...
}
Off-by-One Errors
// Classic mistake
for (let i = 0; i <= array.length; i++) {
// Should be <, not <=
console.log(array[i]);
}
Asynchronous Timing
// Race condition
let data;
fetchData().then((result) => {
data = result;
});
console.log(data); // undefined! Promise hasn't resolved yet
// Solution: Use await
const data = await fetchData();
console.log(data); // Correct value
Prevention is Better Than Cure
Write Testable Code
// Hard to test
function handleSubmit() {
const data = document.getElementById('form').value;
fetch('/api/save', { method: 'POST', body: data });
window.alert('Saved!');
}
// Testable
function handleSubmit(getData, saveData, showMessage) {
const data = getData();
return saveData(data).then(() => showMessage('Saved!'));
}
Use TypeScript
Type checking catches entire classes of bugs before runtime:
interface User {
id: number;
name: string;
email: string;
}
function getUser(id: number): User {
// TypeScript ensures return type matches
}
Leverage Linters
ESLint can catch common mistakes:
- Unused variables
- Missing return statements
- Potential null references
The Debugging Checklist
When stuck, ask yourself:
- Have I reproduced the bug reliably?
- Have I checked the obvious (typos, missing imports)?
- Have I verified my assumptions?
- Have I checked the documentation?
- Have I searched for similar issues?
- Have I taken a break and come back fresh?
Conclusion
Debugging is a skill that improves with practice. Stay systematic, stay curious, and remember: every bug is an opportunity to understand your code better.
Happy debugging! 🔍