SLIs, SLOs and Error Budgets
Measure what users feel, set targets on the tail, and use the error budget to decide whether to ship or stabilise.
burn_rate = observed_error_rate / (1 - SLO)An SLI is a number measured from the system that tracks what a user experiences: the proportion of requests that succeed, the proportion served under 300 ms. An SLO is a target for that SLI over a window. An SLA is a contract with money attached and is always looser than the SLO you run to. The point is not to reach 100%. It is to agree how much failure is acceptable, so the remainder can be spent deliberately on change.
The error budget
A 99.9% SLO over 30 days leaves 0.1% of events: 43 minutes fully down, or a tenth of a percent of requests failing all month. Burn rate is the observed error rate divided by the budgeted rate, so a burn rate of 1 exhausts the budget exactly at the end of the window. Google's Site Reliability Workbook recommends alerting on several windows at once: page at burn rate 14.4 over 1 hour (2% of the budget, confirmed by a 5-minute short window), page at 6 over 6 hours (5%), and open a ticket at 1 over 3 days (10%). That alerts on budget being lost, not on a CPU graph. Budget gone means stop shipping features and fix reliability; budget intact means ship.
Measure the tail, where the user is
Averages hide the users you lose. Dean and Barroso's The Tail at Scale makes it concrete: a request fanning out to 100 servers whose p99 is one second is slow about 63% of the time, because 1 - 0.99 ** 100 is 0.634. Set latency objectives on p99, watch p99.9, and measure at the edge: a 100% server-side success rate is perfectly compatible with a site nobody can load.
Example
// Error budget and burn rate from raw event counts, plus the multi-window
// alert thresholds from Google's Site Reliability Workbook.
const SLO = 0.999; // 99.9% of requests succeed
const WINDOW_DAYS = 30;
function budget({ good, total }) {
const allowedFailures = total * (1 - SLO);
const failures = total - good;
return {
sli: good / total,
allowedFailures,
failures,
// Fraction of the budget still unspent. Negative means the SLO is missed.
remainingFraction: 1 - failures / allowedFailures,
};
}
// Burn rate is relative to the SLO, not absolute: at 99.9%, a 1.44% error
// rate is a burn rate of 14.4 and empties a 30-day budget in ~50 hours.
const burnRate = (errorRate) => errorRate / (1 - SLO);
// Workbook recommendation. Both windows must breach, which is what keeps a
// one-minute blip from paging and still catches a fast burn quickly.
const ALERTS = [
{ severity: 'page', burnRate: 14.4, longWindow: '1h', shortWindow: '5m', budgetSpent: '2%' },
{ severity: 'page', burnRate: 6, longWindow: '6h', shortWindow: '30m', budgetSpent: '5%' },
{ severity: 'ticket', burnRate: 1, longWindow: '3d', shortWindow: '6h', budgetSpent: '10%' },
];
function firingAlerts(errorRateByWindow) {
return ALERTS.filter((a) =>
burnRate(errorRateByWindow[a.longWindow]) >= a.burnRate &&
burnRate(errorRateByWindow[a.shortWindow]) >= a.burnRate);
}
// Worked example: 9,400 failures out of 8,000,000 requests this month.
const month = budget({ good: 7_990_600, total: 8_000_000 });
console.log(month.sli.toFixed(5)); // 0.99883 -> SLO missed
console.log(month.allowedFailures); // 8000
console.log(month.remainingFraction.toFixed(2)); // -0.18 -> 118% of budget spent
// Current error rates per window, as a Prometheus query would return them.
console.log(firingAlerts({ '5m': 0.03, '1h': 0.02, '30m': 0.03, '6h': 0.004, '3d': 0.001 }));
// -> the 14.4 / 1h page fires; the 6 / 6h one does not yetWhen to use it
- Turning a vague 'the API should be fast' requirement into a measurable 99.9% of requests under 300 ms.
- Deciding whether this week's feature work should become reliability work instead.
- Replacing a CPU-threshold page with one that fires on how fast the error budget is being spent.
More examples
The burn-rate alert as Prometheus rules
This is the shape an SLO takes once it has to page somebody. Two things in it are easy to get wrong. The short window exists for reset time, since a one-hour window alone keeps paging for an hour after the incident ends. The latency rule is a proportion of good requests rather than a quantile, which is the form that aggregates correctly across instances, where averaging each instance's p99 does not.
# Two recording rules keep the expressions readable and let the ratio be
# computed once. 'code' here is the HTTP status class label.
groups:
- name: slo-checkout
rules:
- record: job:sli_error_ratio:rate1h
expr: |
sum(rate(http_requests_total{job="checkout",code=~"5.."}[1h]))
/ sum(rate(http_requests_total{job="checkout"}[1h]))
- record: job:sli_error_ratio:rate5m
expr: |
sum(rate(http_requests_total{job="checkout",code=~"5.."}[5m]))
/ sum(rate(http_requests_total{job="checkout"}[5m]))
# 14.4 x the budgeted 0.1% error rate = 1.44%. Both windows must breach:
# the long one for precision, the short one so the alert also RESOLVES
# quickly once the burn stops.
- alert: CheckoutErrorBudgetBurningFast
expr: |
job:sli_error_ratio:rate1h > (14.4 * 0.001)
and
job:sli_error_ratio:rate5m > (14.4 * 0.001)
labels:
severity: page
annotations:
summary: "Checkout is burning 2% of the 30-day error budget per hour"
# Latency SLI from a histogram, not from an averaged quantile: count the
# requests inside the bound and divide. 'le' is the bucket upper bound.
- record: job:sli_latency_ratio:rate30m
expr: |
sum(rate(http_request_duration_seconds_bucket{job="checkout",le="0.3"}[30m]))
/ sum(rate(http_request_duration_seconds_count{job="checkout"}[30m]))An SLI that reflects what the user got
Work through this when a dashboard says 99.99% and users are complaining, because the gap is almost always one of these four. Excluding client errors and deliberate throttling stops your budget paying for other people's bugs. Folding latency into the availability SLI avoids the argument about whether a 12-second success counts. The last one is the hardest to retrofit and the most valuable: everything between your load balancer and the user is invisible to server-side metrics, and it is where a surprising share of real unavailability lives.
// Most SLIs are wrong in the same way: they count what the server did, not
// what the user experienced. These are the corrections that matter.
function classify(event) {
// 1. Not every error is yours. A 400 is the client's bug and must not
// spend your budget; a 429 you issued deliberately must not either.
if (event.status === 400 || event.status === 404 || event.status === 429) {
return 'excluded';
}
// 2. A slow success is a failure. Fold the latency objective into the
// availability SLI so there is one number, not two that disagree.
const LATENCY_OBJECTIVE_MS = 300;
if (event.status < 500 && event.durationMs <= LATENCY_OBJECTIVE_MS) return 'good';
return 'bad';
}
// 3. Weight by user journey, not by URL volume. A health check polled every
// second will otherwise outvote checkout in your availability number.
const JOURNEYS = {
'POST /checkout': { weight: 10, objectiveMs: 1000 },
'GET /product/:id': { weight: 3, objectiveMs: 300 },
'GET /healthz': { weight: 0, objectiveMs: 50 }, // excluded from the SLI
};
// 4. Measure where the user is. Server-side metrics cannot see DNS failures,
// TLS errors, a saturated load balancer or a bad CDN POP, so pair them
// with real-user timings reported from the browser.
function reportFromBrowser() {
const nav = performance.getEntriesByType('navigation')[0];
navigator.sendBeacon('/rum', JSON.stringify({
ttfbMs: nav.responseStart - nav.requestStart,
// The number users actually feel, which no server-side timer records.
loadMs: nav.loadEventEnd - nav.startTime,
}));
}The error budget policy, written before you need it
This is what separates an SLO from a dashboard. The budget's purpose is to make 'ship or stabilise' a reading rather than an argument, which only works if the thresholds and the exemptions are agreed while nothing is on fire. The comment at the end is the part most teams miss: a budget that never gets spent means the objective is below what the service already delivers, so it is costing engineering effort and buying nothing anyone noticed.
// The budget is only useful if spending it changes a decision in advance.
// This is the policy as code, which is what a release gate consults.
const POLICY = {
// Agreed by the team and whoever owns the roadmap, BEFORE an incident.
thresholds: [
{ remaining: 0.5, action: 'ship freely' },
{ remaining: 0.25, action: 'ship, and spend one engineer on reliability' },
{ remaining: 0.0, action: 'feature freeze: reliability work and rollbacks only' },
{ remaining: -Infinity, action: 'freeze, plus a written review before unfreezing' },
],
// Exemptions, so the policy survives contact with reality.
exempt: ['security patches', 'changes that reduce the burn rate'],
};
function gate({ remainingFraction, changeKind }) {
if (POLICY.exempt.includes(changeKind)) return { allow: true, reason: 'exempt' };
const rule = POLICY.thresholds.find((t) => remainingFraction >= t.remaining);
return { allow: rule.remaining > 0, action: rule.action };
}
console.log(gate({ remainingFraction: 0.62, changeKind: 'feature' }));
// { allow: true, action: 'ship freely' }
console.log(gate({ remainingFraction: -0.18, changeKind: 'feature' }));
// { allow: false, action: 'freeze, plus a written review before unfreezing' }
// A budget that is never spent is an SLO set too low: it is buying reliability
// nobody asked for. Treat a consistently full budget as a signal to tighten
// the objective or to ship faster, not as a success.
Discussion