Introduction
Most software is built for ideal conditions: fast networks, reliable APIs, predictable users, clean inputs, and stable traffic. But production environments rarely behave that way.
Real systems face slow responses, partial failures, unexpected inputs, dependency issues, and sudden traffic spikes. Designing for these conditions is what separates working code from reliable software.
This note focuses on practical engineering decisions behind building software for real-world conditions, especially the patterns that improve resilience, reliability, graceful degradation, and user experience during failure.
The Problem
During development, everything often appears stable. APIs respond quickly, databases are small, usage is limited, and failures are rare. This creates a false sense of reliability.
Common Failures
- APIs fail, timeout, or return inconsistent data
- Network latency delays important user actions
- Users submit unexpected input or repeat actions quickly
- Traffic spikes overload APIs, databases, or background jobs
Engineering Impact
- Failures become unpredictable and harder to debug
- Users see broken screens instead of graceful fallback states
- Slow dependencies block the entire request flow
- Small reliability gaps become production incidents
Systems that are not designed for real conditions often fail silently, recover poorly, or create confusing user experiences when something goes wrong.
System Design / Approach
Building for real conditions means assuming that failures will happen. The goal is not to prevent every failure, but to control the impact and keep the system understandable when problems occur.
1. Validate and Defend System Boundaries
User input, API responses, webhook payloads, and external data should be validated before they affect business logic or storage.
2. Use Retries and Timeouts Carefully
Transient failures should retry when safe, but long-running operations should have timeouts so they do not block the system forever.
3. Design Fallback Paths
When dependencies fail, the product should still provide a useful response, degraded state, cached result, or clear explanation to the user.
Implementation
Step 1: Add Retry Logic
Retry transient failures instead of failing immediately. This is useful for temporary network issues, unstable third-party APIs, and short dependency interruptions.
async function fetchWithRetry(url: string) {
for (let attempt = 1; attempt <= 3; attempt++) {
try {
return await fetch(url);
} catch (error) {
if (attempt === 3) {
throw new Error("Failed after retries");
}
}
}
}
Retries improve resilience against temporary issues, but they should be used carefully to avoid increasing load during outages.
Step 2: Use Timeouts
Timeouts prevent slow operations from blocking the system indefinitely. When an external dependency becomes slow, the application should stop waiting and move into a controlled failure path.
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, 3000);
try {
await fetch(url, {
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
Timeouts keep the system responsive and prevent one slow dependency from holding the entire user flow hostage.
Step 3: Handle Failures Gracefully
When dependencies fail, the application should avoid crashing the full experience. A fallback response, cached value, or empty state can keep the product usable.
try {
return await fetchData();
} catch {
return {
data: [],
fallback: true,
message: "Showing limited data right now.",
};
}
Graceful degradation improves user experience because the product can still explain what happened and continue safely.
Trade-offs
| Approach | Benefit | Cost |
|---|---|---|
| Retries | Higher success rate during temporary failures | Can increase latency and load if retry rules are too aggressive |
| Timeouts | Prevents slow dependencies from blocking the whole system | Can fail early if timeout limits are too strict |
| Fallbacks | Keeps the product usable during partial failures | May show incomplete, cached, or limited data |
Real-World Impact
Lower Downtime
The system becomes more resilient because temporary failures are handled instead of immediately breaking the flow.
Better Failure UX
Users get clearer fallback states instead of blank screens, silent crashes, or confusing broken behavior.
Predictable Reliability
The application behaves more predictably under latency, dependency failure, unexpected input, and traffic spikes.