How I Cut Debugging Time in Half: 4 Practices That Actually Work
I used to spend hours chasing bugs that, in hindsight, should have taken 15 minutes. You know the pattern: stare at code, add print statements, refresh, stare more, eventually fix the wrong thing, repeat.
After shipping production code for a few years and debugging over 100 incidents, I've found four practices that consistently cut my debugging time. Not "productivity hacks" — just actual habits that changed how I approach broken code.
1. Structured Logging Before You Need It
The worst time to realize your logs are useless is at 2 AM during an incident.
I learned this the hard way. We had a payment processing service that would occasionally fail with a generic "transaction error." The logs? Just the error message. No request ID, no payload, no upstream response.
What I do now: Every HTTP request gets a unique request_id (UUID) that propagates through all downstream calls:
import uuid
import logging
from contextvars import ContextVar
request_id_var: ContextVar[str] = ContextVar("request_id")
class RequestIdFilter(logging.Filter):
def filter(self, record):
try:
record.request_id = request_id_var.get()
except LookupError:
record.request_id = "no-request-id"
return True
def log_incoming(func):
async def wrapper(request, *args, **kwargs):
rid = str(uuid.uuid4())
request_id_var.set(rid)
logger.info("request_start", extra={"method": request.method, "path": request.url.path})
try:
response = await func(request, *args, **kwargs)
logger.info("request_end", extra={"status": response.status_code})
return response
except Exception:
logger.exception("request_error")
raise
return wrapper
Use structured logging (JSON), not formatted strings. You can query request_id=abc123 and see the entire lifecycle of that one request — from gateway through service to database and back.
2. Reproduce First, Fix Second
This sounds obvious. But I've watched developers (including past me) jump straight into code changes based on a vague error description. "The search page is slow" → start adding indexes. Wrong move.
The rule: If you can't reproduce it, you don't understand it.
My workflow now:
- Capture the exact input — request payload, user session, timing. Screenshots aren't enough.
- Reproduce locally with that input — if you can't, the bug isn't what you think it is.
- Write a failing test — this is your proof you understand the problem.
I once spent two hours "fixing" a race condition that didn't exist. The real problem was a misconfigured load balancer timeout. If I'd actually reproduced the issue — sent the same request payload and watched the full trace — I would have spotted it in 10 minutes.
Hard rule: no code change until I've reproduced the failure with my own eyes.
3. Binary Search Debugging
When the bug could be anywhere in a complex system, don't guess. Bisect.
The technique is the same as git bisect, but applied to the runtime pipeline:
- Check the middle: Is it still broken if you skip half the steps?
- Narrow down: Which half contains the problem?
- Repeat: Until you're down to one function or one configuration change.
Example from a real incident: Users reported intermittent 500 errors on a dashboard page. The pipeline was: Browser → CDN → API Gateway → Auth Service → Dashboard Service → Database.
Instead of checking each layer sequentially, I started in the middle:
- Hit the Dashboard Service directly (bypass gateway + auth) → still failing. Right half (Auth→DB) is ruled out.
- Check the gateway logs → errors there too. Left half contains the issue.
- Compare CDN vs direct-to-gateway → CDN was fine. Gateway was the culprit.
- Gateway config diff → someone had changed the timeout from 30s to 5s a day earlier.
Twenty minutes total. Sequential debugging would have taken hours.
4. The Debug Journal
I keep a plain text file open during any non-trivial debugging session. Every observation goes in:
14:05 - user reports "page not loading" with screenshot
14:08 - reproduced on staging. 502 from upstream
14:12 - upstream service logs show OOMKilled
14:15 - memory graph shows spike at 13:45 — same time as batch job
14:20 - confirmed: batch job memory leak, fixed in v2.1 but not deployed
14:30 - deployed v2.1, monitoring for 5 min, stable
Why this works:
- Prevents looping — if you've already tried something, the journal shows it. No repeating dead ends.
- Captures context — 3 hours later, you won't remember the exact error message. The journal does.
- Makes handoffs easy — paste the journal into the incident ticket and someone else can pick it up.
- Builds pattern recognition — after 50 entries, you start seeing recurring failure modes.
It sounds too simple to matter. But after 100+ incidents, my journal has caught patterns I wouldn't have noticed otherwise: "third time this month the Redis connection pool exhausts under batch load" — that's not a bug, that's a design problem.
Putting It Together
None of these are revolutionary. They just require doing them consistently:
| Practice | Before (time wasted) | After |
|---|---|---|
| Structured logging | Hours searching scattered logs | 30 seconds with request ID |
| Reproduce first | 2h fixing wrong thing | 10 min finding real cause |
| Binary search | 1h checking every layer | 15 min of bisecting |
| Debug journal | Repeating dead ends | Never loops, builds patterns |
The hardest one for me was "reproduce first." There's a strong urge to just do something — change code, restart a service, clear a cache. Resist it. Understanding must come before action.
What debugging habits made the biggest difference for you? I'm always looking for new ones.
Top comments (0)