Designing a Status Page System: Health Aggregation, Incident Communication, and Subscriber Notifications at Scale
A deep dive into the architecture of a production status page system: health check aggregation, incident state machines, subscriber notification fan-out, and the operational challenge of keeping your status page up when everything else is down.
Your status page has one job: be available and accurate when your production system is not. That constraint shapes every design decision. The database your application uses cannot be the same one your status page reads from. The deployment pipeline that takes down your API cannot take down the status page with it. And the notification system that alerts subscribers cannot depend on the service that just went down.
This is the core tension in status page architecture. Everything else follows from it.
The Data Model
Before anything else, define what you are actually tracking. A status page aggregates two distinct things: real-time component health and human-authored incident records. These are related but not the same.
type ComponentStatus = "operational" | "degraded" | "partial_outage" | "major_outage" | "maintenance";
interface Component {
id: string;
name: string;
description: string;
groupId?: string; // for grouping related components
status: ComponentStatus;
lastCheckedAt: Date;
order: number;
}
type IncidentStatus =
| "investigating"
| "identified"
| "monitoring"
| "resolved";
interface Incident {
id: string;
title: string;
status: IncidentStatus;
impact: "none" | "minor" | "major" | "critical";
affectedComponentIds: string[];
createdAt: Date;
resolvedAt?: Date;
updates: IncidentUpdate[];
}
interface IncidentUpdate {
id: string;
incidentId: string;
status: IncidentStatus;
body: string;
createdAt: Date;
createdBy: string;
}
The separation matters. Component status is computed from health checks. Incident records are human-authored narratives that communicate what is happening and why. Conflating them leads to a status page that either updates too fast (every failed health check creates noise) or too slow (humans forget to post updates).
Health Aggregation
Health signals come from two directions: pull-based checks (your system polls endpoints) and push-based checks (services report their own health). Both have legitimate uses.
Pull-based checks are simpler to operate and work for third-party integrations. You control the check cadence, and a failed check is unambiguous. The downside: you are adding network hops, and a slow check might report degradation that is actually a check infrastructure problem, not a service problem.
Push-based checks reduce false positives and give you richer telemetry. Services can report partial degradation before it is externally visible. The downside: if a service crashes without sending a final heartbeat, you have a stale signal until the heartbeat TTL expires.
A production aggregation engine typically uses both.
interface HealthCheckConfig {
componentId: string;
type: "http" | "tcp" | "heartbeat";
target: string;
intervalMs: number;
timeoutMs: number;
expectedStatus?: number; // for HTTP checks
degradedThreshold: number; // consecutive failures before "degraded"
outageThreshold: number; // consecutive failures before "outage"
}
interface HealthCheckResult {
componentId: string;
success: boolean;
latencyMs: number;
checkedAt: Date;
error?: string;
}
class HealthAggregator {
private consecutiveFailures = new Map<string, number>();
async runCheck(config: HealthCheckConfig): Promise<HealthCheckResult> {
const start = Date.now();
try {
if (config.type === "http") {
const res = await fetch(config.target, {
signal: AbortSignal.timeout(config.timeoutMs),
});
const success = res.status === (config.expectedStatus ?? 200);
return {
componentId: config.componentId,
success,
latencyMs: Date.now() - start,
checkedAt: new Date(),
error: success ? undefined : `HTTP ${res.status}`,
};
}
// TCP and heartbeat checks omitted for brevity
throw new Error(`Unsupported check type: ${config.type}`);
} catch (err) {
return {
componentId: config.componentId,
success: false,
latencyMs: Date.now() - start,
checkedAt: new Date(),
error: err instanceof Error ? err.message : String(err),
};
}
}
computeStatus(
componentId: string,
result: HealthCheckResult,
config: HealthCheckConfig
): ComponentStatus {
if (result.success) {
this.consecutiveFailures.set(componentId, 0);
return "operational";
}
const failures = (this.consecutiveFailures.get(componentId) ?? 0) + 1;
this.consecutiveFailures.set(componentId, failures);
if (failures >= config.outageThreshold) return "major_outage";
if (failures >= config.degradedThreshold) return "degraded";
return "operational"; // still within tolerance
}
}
One subtlety: a single failed check should not immediately flip a component to “outage.” Transient network errors, brief DNS hiccups, and deploy restarts all cause momentary check failures. Use consecutive failure thresholds, not single-sample thresholds.
Computing Overall System Status
When you have dozens of components, you need a single top-level status. This is where most teams get it wrong. The naive approach is “if any component is degraded, the system is degraded.” But that treats a degraded internal tooling component the same as a degraded payment processor.
Weight components by criticality.
type CriticalityTier = "critical" | "major" | "minor";
interface ComponentConfig extends Component {
criticality: CriticalityTier;
}
function computeSystemStatus(components: ComponentConfig[]): ComponentStatus {
const criticalComponents = components.filter(c => c.criticality === "critical");
const majorComponents = components.filter(c => c.criticality === "major");
const hasStatus = (
list: ComponentConfig[],
status: ComponentStatus
) => list.some(c => c.status === status);
// Any critical outage = major_outage system-wide
if (hasStatus(criticalComponents, "major_outage")) return "major_outage";
// Any critical degradation or major component outage = partial_outage
if (
hasStatus(criticalComponents, "degraded") ||
hasStatus(criticalComponents, "partial_outage") ||
hasStatus(majorComponents, "major_outage")
) {
return "partial_outage";
}
// Major component degradation = degraded
if (hasStatus(majorComponents, "degraded")) return "degraded";
// Only minor components affected
if (components.some(c => c.status !== "operational" && c.status !== "maintenance")) {
return "degraded";
}
return "operational";
}
The rules are explicit and auditable. When an incident review asks why the status page showed “degraded” instead of “partial_outage” at 14:37, you can point at this function.
The Incident State Machine
Incidents move through a defined lifecycle. Treating this as a state machine prevents invalid transitions and makes the audit log coherent.
const VALID_TRANSITIONS: Record<IncidentStatus, IncidentStatus[]> = {
investigating: ["identified", "monitoring", "resolved"],
identified: ["monitoring", "resolved"],
monitoring: ["identified", "resolved"], // can regress
resolved: [], // terminal state
};
class IncidentStateMachine {
transition(
incident: Incident,
newStatus: IncidentStatus,
update: string,
author: string
): Incident {
const allowed = VALID_TRANSITIONS[incident.status];
if (!allowed.includes(newStatus)) {
throw new Error(
`Invalid transition: ${incident.status} -> ${newStatus}`
);
}
const now = new Date();
const incidentUpdate: IncidentUpdate = {
id: crypto.randomUUID(),
incidentId: incident.id,
status: newStatus,
body: update,
createdAt: now,
createdBy: author,
};
return {
...incident,
status: newStatus,
resolvedAt: newStatus === "resolved" ? now : incident.resolvedAt,
updates: [...incident.updates, incidentUpdate],
};
}
}
The “monitoring” to “identified” regression is intentional. Incidents that reoccur after appearing to stabilize are common. The state machine should allow for that rather than forcing teams to create a second incident.
Subscriber Notification Fan-Out
When an incident is created or updated, you need to notify subscribers. Subscribers register via email, SMS, webhook, or RSS. At scale, fan-out to thousands of subscribers must be asynchronous and resilient to partial failures.
The architecture: write the notification intent to a queue. Workers pull from the queue and deliver per channel. If delivery fails, retry with exponential backoff. Dead-letter failed deliveries for investigation.
interface Subscriber {
id: string;
channel: "email" | "sms" | "webhook" | "rss";
target: string; // email address, phone number, webhook URL
componentIds?: string[]; // undefined = subscribe to everything
verifiedAt: Date;
}
interface NotificationJob {
id: string;
incidentId: string;
updateId: string;
subscriberId: string;
channel: Subscriber["channel"];
target: string;
payload: NotificationPayload;
attempts: number;
createdAt: Date;
nextAttemptAt: Date;
}
interface NotificationPayload {
subject: string;
body: string;
incidentUrl: string;
}
async function fanOutNotifications(
incident: Incident,
update: IncidentUpdate,
subscribers: Subscriber[]
): Promise<void> {
const affected = new Set(incident.affectedComponentIds);
const eligible = subscribers.filter(
sub =>
!sub.componentIds ||
sub.componentIds.some(id => affected.has(id))
);
const jobs: NotificationJob[] = eligible.map(sub => ({
id: crypto.randomUUID(),
incidentId: incident.id,
updateId: update.id,
subscriberId: sub.id,
channel: sub.channel,
target: sub.target,
payload: buildPayload(incident, update),
attempts: 0,
createdAt: new Date(),
nextAttemptAt: new Date(),
}));
// Write jobs to queue in batches to avoid overwhelming the broker
const BATCH_SIZE = 500;
for (let i = 0; i < jobs.length; i += BATCH_SIZE) {
await queue.enqueueMany(jobs.slice(i, i + BATCH_SIZE));
}
}
function buildPayload(incident: Incident, update: IncidentUpdate): NotificationPayload {
return {
subject: `[${update.status.toUpperCase()}] ${incident.title}`,
body: update.body,
incidentUrl: `https://status.example.com/incidents/${incident.id}`,
};
}
For webhook delivery, treat each endpoint as an untrusted external system. Set tight timeouts (5 seconds max), validate response codes, and never block the queue on a slow webhook consumer.
async function deliverWebhook(job: NotificationJob): Promise<void> {
const body = JSON.stringify({
incident_id: job.incidentId,
update_id: job.updateId,
...job.payload,
});
const res = await fetch(job.target, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Status-Signature": sign(body, webhookSecret),
},
body,
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
throw new Error(`Webhook delivery failed: HTTP ${res.status}`);
}
}
Sign webhook payloads using HMAC-SHA256. Subscribers need a way to verify the payload came from you, not an attacker who discovered their webhook URL.
Historical Uptime Calculation
Uptime percentages are computed from the health check history, not from incidents. An incident might last 45 minutes but health checks might have shown degradation for only 12 of those minutes. Track both.
interface UptimeWindow {
componentId: string;
periodStart: Date;
periodEnd: Date;
uptimePercent: number;
totalChecks: number;
failedChecks: number;
}
function calculateUptime(
results: HealthCheckResult[],
periodStart: Date,
periodEnd: Date
): Map<string, UptimeWindow> {
const byComponent = new Map<string, HealthCheckResult[]>();
for (const result of results) {
if (result.checkedAt < periodStart || result.checkedAt > periodEnd) continue;
const existing = byComponent.get(result.componentId) ?? [];
existing.push(result);
byComponent.set(result.componentId, existing);
}
const windows = new Map<string, UptimeWindow>();
for (const [componentId, componentResults] of byComponent) {
const failed = componentResults.filter(r => !r.success).length;
const total = componentResults.length;
windows.set(componentId, {
componentId,
periodStart,
periodEnd,
totalChecks: total,
failedChecks: failed,
uptimePercent: total === 0 ? 100 : ((total - failed) / total) * 100,
});
}
return windows;
}
Store raw check results in a time-series store (ClickHouse, TimescaleDB, or even a partitioned PostgreSQL table) rather than in your primary application database. You will query by time range far more than by component ID, and time-series storage aligns with that access pattern.
Tradeoffs
| Decision | Option A | Option B | When to choose A |
|---|---|---|---|
| Health check model | Pull (you poll) | Push (services report) | Third-party integrations, simpler ops |
| Notification delivery | Synchronous in-process | Async queue | Always use queues at >100 subscribers |
| Status page storage | Application database | Edge KV / CDN | Edge KV for availability isolation |
| Uptime storage | PostgreSQL partitioned | ClickHouse / TimescaleDB | ClickHouse above ~10M rows/month |
| Component grouping | Flat list | Hierarchical groups | Groups when you have >20 components |
| Notification channels | Email only | Email + SMS + webhook | Add channels based on subscriber demand |
The Availability Problem
The hardest constraint is also the most important: the status page must be available when your production systems are down. This means:
Separate infrastructure. The status page must not share a database, queue, load balancer, or CDN configuration with the services it monitors. Deploy to a different cloud region, different account, or a dedicated edge platform.
Static rendering at the edge. The read path for a status page is extremely hot during incidents. Thousands of users refresh every few seconds. Serve pre-rendered HTML from a CDN edge node. Rebuild the static page on every status change and push to the edge. This decouples read traffic from your application servers entirely.
Health check workers as a separate deployment unit. If your health check workers share a deployment with your API, an API deploy can take down monitoring. Run health check workers on separate instances with separate deployment pipelines.
Degraded mode for the status page itself. If your status page backend is unreachable, the frontend should display the last known state with a “last updated at X” timestamp rather than showing an error. Stale-but-visible is better than unavailable.
Geographic distribution of health checks. A check that fails from us-east-1 but succeeds from eu-west-1 is likely a regional issue, not a full outage. Run checks from multiple regions and require a quorum of check workers to agree before flipping a component status.
interface RegionalCheckResult {
region: string;
result: HealthCheckResult;
}
function aggregateRegionalResults(
results: RegionalCheckResult[],
quorumFraction: number = 0.5
): boolean {
if (results.length === 0) return true; // no data, assume operational
const failures = results.filter(r => !r.result.success).length;
return failures / results.length < quorumFraction;
}
With this approach, a single check worker going down does not cause false-positive outage reports.
Operational Considerations
Maintenance windows need first-class support. Scheduled maintenance should suppress notifications and show a “maintenance” status rather than “outage.” Implement a maintenance schedule model and skip health check status updates during active maintenance windows.
Subscriber list hygiene matters at scale. Email addresses bounce. Webhook endpoints disappear. Run a periodic job to disable subscribers with repeated delivery failures. Require email verification before activating subscriptions.
Rate limiting on the subscriber creation endpoint is non-optional. Without it, a single attacker can register millions of email addresses and use your status page as a spam relay.
Audit logging for every incident state transition, every component status override, and every subscriber action. When a post-incident review asks why the status page said “operational” at 15:42, you need a queryable log, not memory.
Manual override capability is critical. Health checks are automated, but sometimes an engineer knows the system is down before the checks catch it. Build an explicit “override status” feature that lets an operator set a component status directly, bypassing the computed status.
The status page is infrastructure. Build it with the same discipline you would apply to a write-ahead log or a distributed lock. It has to work when nothing else does.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.