Elke SaaS-applicatie krijgt te maken met fouten. API's die niet reageren, databases die even wegvallen, externe diensten die trager worden onder piekbelasting. Het verschil tussen een professionele SaaS en een hobbyproject zit in hoe je met die fouten omgaat.
In dit artikel duiken we diep in de patronen en strategieën die je SaaS betrouwbaar houden — zelfs wanneer alles om je heen faalt.
Waarom foutafhandeling cruciaal is voor SaaS
Bij een traditionele webapplicatie is een fout vervelend. Bij SaaS is het potentieel fataal. Je klanten draaien hun bedrijfsprocessen op jouw platform. Downtime betekent verloren omzet — niet alleen voor jou, maar voor al je klanten tegelijk.
Enkele cijfers die dit onderstrepen:
- 53% van gebruikers verlaat een app die meer dan 3 seconden laadt
- Een enkele minuut downtime kan bij enterprise SaaS duizenden euro's kosten
- 80% van churn wordt veroorzaakt door slechte betrouwbaarheid, niet door ontbrekende features
Het retry-patroon: slim opnieuw proberen
De eenvoudigste vorm van veerkracht is opnieuw proberen. Maar naïeve retries kunnen je probleem verergeren.
Exponential backoff met jitter
async function withRetry<T>(
fn: () => Promise<T>,
options: {
maxRetries?: number;
baseDelay?: number;
maxDelay?: number;
} = {}
): Promise<T> {
const { maxRetries = 3, baseDelay = 1000, maxDelay = 30000 } = options;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries) throw error;
// Exponential backoff met jitter
const exponentialDelay = baseDelay * Math.pow(2, attempt);
const jitter = Math.random() * exponentialDelay * 0.5;
const delay = Math.min(exponentialDelay + jitter, maxDelay);
console.warn(
\`Poging \${attempt + 1} mislukt, opnieuw over \${Math.round(delay)}ms\`
);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Unreachable');
}
// Gebruik
const data = await withRetry(
() => fetch('https://api.payment-provider.com/charge'),
{ maxRetries: 3, baseDelay: 500 }
);
Welke fouten retry je wél en niet?
Niet elke fout verdient een retry. Maak onderscheid:
| Wel retryen | Niet retryen |
|---|---|
| 429 Too Many Requests | 400 Bad Request |
| 500 Internal Server Error | 401 Unauthorized |
| 503 Service Unavailable | 403 Forbidden |
| Netwerk-timeouts | 404 Not Found |
| DNS-resolutiefouten | 422 Validation Error |
Vuistregel: retry transiente fouten (die vanzelf kunnen oplossen), niet permanente fouten (die elke keer hetzelfde resultaat geven).
Het circuit breaker-patroon
Stel je een stroomonderbreker in je meterkast voor. Als er kortsluiting is, schakelt de zekering uit om verdere schade te voorkomen. Hetzelfde principe werkt voor API-calls.
class CircuitBreaker {
private failures = 0;
private lastFailure: number | null = null;
private state: 'closed' | 'open' | 'half-open' = 'closed';
constructor(
private threshold: number = 5,
private resetTimeout: number = 60000
) {}
async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'open') {
if (Date.now() - (this.lastFailure || 0) > this.resetTimeout) {
this.state = 'half-open';
} else {
throw new Error('Circuit breaker is open — service tijdelijk niet beschikbaar');
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess() {
this.failures = 0;
this.state = 'closed';
}
private onFailure() {
this.failures++;
this.lastFailure = Date.now();
if (this.failures >= this.threshold) {
this.state = 'open';
console.error(\`Circuit breaker geopend na \${this.failures} fouten\`);
}
}
}
// Per externe service een eigen circuit breaker
const paymentCircuit = new CircuitBreaker(5, 30000);
const emailCircuit = new CircuitBreaker(3, 60000);
// Gebruik
const charge = await paymentCircuit.execute(
() => stripe.charges.create({ amount: 2000, currency: 'eur' })
);
De drie states van een circuit breaker
- Closed (normaal): Alle requests gaan door. Fouten worden geteld.
- Open (geblokkeerd): Alle requests falen direct, zonder de service te benaderen. Dit voorkomt dat je een al overbelaste service nog meer belast.
- Half-open (testen): Na een cooldown-periode laat je één request door om te testen of de service weer beschikbaar is.
Graceful degradation: beter half dan niets
Niet elke feature is even belangrijk. Als je betalingsprovider uitvalt, is dat kritiek. Als je analytics-service even niet reageert? Dan kan je app prima doordraaien.
interface DashboardData {
revenue: number;
activeUsers: number;
analytics?: AnalyticsData; // Optioneel — niet kritiek
recommendations?: Product[]; // Optioneel — niet kritiek
}
async function getDashboardData(tenantId: string): Promise<DashboardData> {
// Kritieke data — moet slagen
const [revenue, activeUsers] = await Promise.all([
getRevenue(tenantId),
getActiveUsers(tenantId),
]);
// Niet-kritieke data — mag falen
const [analytics, recommendations] = await Promise.allSettled([
getAnalytics(tenantId),
getRecommendations(tenantId),
]);
return {
revenue,
activeUsers,
analytics: analytics.status === 'fulfilled' ? analytics.value : undefined,
recommendations: recommendations.status === 'fulfilled' ? recommendations.value : undefined,
};
}
In je frontend toon je dan een subtiele melding: "Sommige data is tijdelijk niet beschikbaar" in plaats van een volle foutpagina.
Timeouts: de vergeten held
Een missende timeout is een van de gevaarlijkste bugs in distributed systems. Zonder timeout hangt je applicatie eindeloos te wachten op een service die nooit gaat antwoorden.
async function fetchWithTimeout(
url: string,
options: RequestInit & { timeoutMs?: number } = {}
): Promise<Response> {
const { timeoutMs = 5000, ...fetchOptions } = options;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...fetchOptions,
signal: controller.signal,
});
return response;
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw new Error(\`Request naar \${url} duurde langer dan \${timeoutMs}ms\`);
}
throw error;
} finally {
clearTimeout(timeout);
}
}
Timeout-budgetten
In een microservices-architectuur moet je nadenken over je totale timeout-budget:
- API Gateway: 30s totale timeout
- Service A → Service B: 10s
- Service B → Database: 5s
- Service B → Cache: 500ms
Elke laag moet een kortere timeout hebben dan de laag erboven. Anders krijg je cascade-timeouts.
Dead letter queues: geen bericht mag verloren gaan
Bij asynchrone verwerking (webhooks, achtergrondtaken) zijn dead letter queues essentieel. Als een bericht na meerdere pogingen niet verwerkt kan worden, sla je het op voor latere inspectie.
async function processWebhook(event: WebhookEvent): Promise<void> {
const MAX_ATTEMPTS = 3;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
await handleEvent(event);
return; // Succes
} catch (error) {
console.error(\`Webhook poging \${attempt}/\${MAX_ATTEMPTS} mislukt:\`, error);
if (attempt === MAX_ATTEMPTS) {
// Naar dead letter queue
await db.deadLetterQueue.create({
data: {
eventType: event.type,
payload: JSON.stringify(event),
error: error.message,
failedAt: new Date(),
attempts: MAX_ATTEMPTS,
},
});
// Alert het team
await notifyOpsTeam(\`Webhook \${event.type} definitief mislukt na \${MAX_ATTEMPTS} pogingen\`);
}
}
}
}
Idempotency: veilig opnieuw verwerken
Als je retries implementeert, moet je garanderen dat dezelfde operatie meerdere keren uitvoeren veilig is. Dit noemen we idempotency.
async function processPayment(idempotencyKey: string, amount: number) {
// Check of we dit al verwerkt hebben
const existing = await db.payment.findUnique({
where: { idempotencyKey },
});
if (existing) {
console.log(\`Betaling \${idempotencyKey} al verwerkt, skip\`);
return existing;
}
// Verwerk de betaling
const payment = await db.payment.create({
data: {
idempotencyKey,
amount,
status: 'pending',
},
});
try {
const charge = await stripe.charges.create(
{ amount, currency: 'eur' },
{ idempotencyKey }
);
return await db.payment.update({
where: { id: payment.id },
data: { status: 'completed', stripeChargeId: charge.id },
});
} catch (error) {
await db.payment.update({
where: { id: payment.id },
data: { status: 'failed', error: error.message },
});
throw error;
}
}
Health checks en readiness probes
Je applicatie moet zelf kunnen communiceren of ze gezond is:
// Health check endpoint
app.get('/health', async (req, res) => {
const checks = {
database: await checkDatabase(),
redis: await checkRedis(),
stripe: await checkStripe(),
};
const healthy = Object.values(checks).every(c => c.status === 'ok');
res.status(healthy ? 200 : 503).json({
status: healthy ? 'healthy' : 'degraded',
checks,
timestamp: new Date().toISOString(),
});
});
async function checkDatabase(): Promise<HealthCheck> {
try {
const start = Date.now();
await db.$queryRaw\`SELECT 1\`;
return { status: 'ok', latencyMs: Date.now() - start };
} catch {
return { status: 'error', message: 'Database niet bereikbaar' };
}
}
Praktische checklist voor jouw SaaS
Voordat je live gaat, loop deze checklist af:
- Retries met exponential backoff op alle externe API-calls
- Circuit breakers op kritieke dependencies
- Timeouts op élke netwerkcall (geen uitzonderingen!)
- Graceful degradation voor niet-kritieke features
- Dead letter queues voor asynchrone verwerking
- Idempotency keys op alle state-wijzigende operaties
- Health check endpoints voor monitoring en orchestratie
- Structured logging zodat je fouten kunt traceren
- Alerting op circuit breaker state-changes
- Runbook voor het team bij veelvoorkomende fouten
Conclusie
Foutafhandeling is geen afterthought — het is een kernfeature van elke serieuze SaaS-applicatie. De patronen in dit artikel (retries, circuit breakers, graceful degradation, idempotency) zijn bewezen technieken die door bedrijven als Netflix, Stripe en AWS worden ingezet.
Begin simpel: voeg timeouts en retries toe aan je externe calls. Bouw daarna circuit breakers rond je kritiekste dependencies. En implementeer graceful degradation zodat je gebruikers altijd een werkende applicatie zien — zelfs als niet alles perfect draait.
Je gebruikers hoeven niet te weten dat er achter de schermen iets misgaat. Ze moeten alleen merken dat jouw app gewoon blijft werken.