---
# === IDENTITY ===
id: software/system-design/realtime-dashboard/2026
canonical_question: "How do I design a real-time analytics dashboard?"
aliases:
  - "How to build a live metrics dashboard at scale?"
  - "Real-time dashboard system design architecture"
  - "Streaming analytics dashboard design patterns"
  - "How to design a real-time monitoring dashboard system?"
entity_type: software_reference
domain: software > system-design > realtime_dashboard
region: global
jurisdiction: global
temporal_scope: 2020-2026

# === VERIFICATION ===
last_verified: 2026-02-23
confidence: 0.90
version: 1.0
first_published: 2026-02-23

# === TEMPORAL VALIDITY ===
temporal_validity:
  status: stable
  last_breaking_change: null
  next_review: 2026-08-22
  change_sensitivity: medium

# === CONSTRAINTS ===
constraints:
  - "Choose push (WebSocket/SSE) vs pull (polling) based on update frequency: push for >1 update/sec, polling for <1 update/min"
  - "Pre-aggregate metrics at ingestion time for dashboards with >10K concurrent viewers; live queries cannot sustain that fanout"
  - "Never query raw event tables directly from the dashboard; always use materialized views or pre-aggregated rollup tables"
  - "WebSocket connections consume ~50KB memory each; budget for max_connections * 50KB on each server node"
  - "Time-series data must be stored with time-based partitioning; unpartitioned tables degrade to full scans beyond 100M rows"

# === SKIP CONDITIONS ===
skip_this_unit_if:
  - condition: "Need a batch analytics warehouse (reports run once per day)"
    use_instead: "software/system-design/data-warehouse/2026"
  - condition: "Need a log aggregation and search system (ELK/Splunk)"
    use_instead: "software/system-design/log-aggregation/2026"

# === AGENT HINTS ===
inputs_needed:
  - key: "update_frequency"
    question: "How frequently must the dashboard update?"
    type: choice
    options: ["sub-second (<1s)", "near-real-time (1-30s)", "periodic (1-5 min)"]
  - key: "concurrent_viewers"
    question: "How many concurrent dashboard viewers do you expect?"
    type: choice
    options: ["<100", "100-10K", ">10K"]
  - key: "data_volume"
    question: "What is your expected event ingestion rate?"
    type: choice
    options: ["<10K events/sec", "10K-1M events/sec", ">1M events/sec"]

# === DISTRIBUTION ===
canonical_source: "https://knowledgelib.io/software/system-design/realtime-dashboard/2026"
suggested_citation: "Source: knowledgelib.io — AI Knowledge Library (verified 2026-02-23)"

# === RELATED UNITS ===
related_kos:
  depends_on: []
  related_to:
    - id: "software/system-design/url-shortener/2026"
      label: "URL Shortener System Design"
    - id: "software/system-design/social-media-feed/2026"
      label: "Social Media Feed System Design"
  solves: []
  alternative_to: []
  often_confused_with:
    - id: "software/system-design/log-aggregation/2026"
      label: "Log Aggregation System Design"

# === SOURCES (7 authoritative sources) ===
sources:
  - id: src1
    title: "Introduction to Apache Druid"
    author: Apache Druid
    url: https://druid.apache.org/docs/latest/design/
    type: official_docs
    published: 2025-12-01
    reliability: authoritative
  - id: src2
    title: "How to Use ClickHouse Materialized Views for Real-Time Aggregations"
    author: OneUptime
    url: https://oneuptime.com/blog/post/2026-01-21-clickhouse-materialized-views/view
    type: technical_blog
    published: 2026-01-21
    reliability: high
  - id: src3
    title: "Set up Grafana Live"
    author: Grafana Labs
    url: https://grafana.com/docs/grafana/latest/setup-grafana/set-up-grafana-live/
    type: official_docs
    published: 2025-10-15
    reliability: authoritative
  - id: src4
    title: "Build Real-Time Apache Kafka Dashboards That Drive Action"
    author: Confluent
    url: https://www.confluent.io/blog/build-real-time-kafka-dashboards/
    type: technical_blog
    published: 2025-06-10
    reliability: high
  - id: src5
    title: "Server-Sent Events vs WebSockets: Key Differences and Use Cases"
    author: Ably
    url: https://ably.com/blog/websockets-vs-sse
    type: technical_blog
    published: 2025-03-20
    reliability: high
  - id: src6
    title: "Kappa Architecture is Mainstream Replacing Lambda"
    author: Kai Waehner
    url: https://www.kai-waehner.de/blog/2021/09/23/real-time-kappa-architecture-mainstream-replacing-batch-lambda/
    type: technical_blog
    published: 2021-09-23
    reliability: high
  - id: src7
    title: "How would you design a real-time analytics dashboard system?"
    author: Design Gurus
    url: https://www.designgurus.io/answers/detail/how-would-you-design-a-real-time-analytics-dashboard-system-handling-live-metrics-and-updates
    type: community_resource
    published: 2025-01-15
    reliability: moderate_high
---

# Real-Time Analytics Dashboard System Design

## TL;DR

- **Bottom line**: A real-time analytics dashboard ingests events through a streaming platform (Kafka/Kinesis), pre-aggregates them into time-windowed rollups via a stream processor (Flink/ClickHouse materialized views), stores results in a low-latency OLAP database (Druid/ClickHouse/TimescaleDB), and pushes updates to clients via WebSocket or SSE connections.
- **Key tool/command**: `CREATE MATERIALIZED VIEW mv_metrics ENGINE = AggregatingMergeTree() AS SELECT ...` (ClickHouse pre-aggregation at ingest time)
- **Watch out for**: Querying raw event tables directly from the dashboard -- at scale this creates unbounded query latency and competes with ingestion for I/O.
- **Works with**: Any language/stack. Core components: Kafka/Kinesis (ingestion), Flink/ksqlDB (processing), ClickHouse/Druid/TimescaleDB (storage), Grafana/custom UI (visualization), WebSocket/SSE (delivery).

## Constraints
<!-- Agents: read this section before recommending any action from this unit.
     These are hard constraints that must not be violated. -->

- Choose push (WebSocket/SSE) vs pull (polling) based on update frequency: push for >1 update/sec, polling for <1 update/min
- Pre-aggregate metrics at ingestion time for dashboards with >10K concurrent viewers; live queries cannot sustain that fanout
- Never query raw event tables directly from the dashboard; always use materialized views or pre-aggregated rollup tables
- WebSocket connections consume ~50KB memory each; budget for max_connections * 50KB per server node
- Time-series data must be stored with time-based partitioning; unpartitioned tables degrade to full scans beyond 100M rows

## Quick Reference

| Component | Role | Technology Options | Scaling Strategy |
|---|---|---|---|
| Event Ingestion | Receives raw events from producers | Apache Kafka, Amazon Kinesis, Apache Pulsar | Partition by event key; add partitions for throughput |
| Stream Processor | Transforms, filters, and windows events | Apache Flink, ksqlDB, Kafka Streams, Spark Structured Streaming | Parallel task slots per partition; checkpoint to durable storage |
| Pre-Aggregation Layer | Computes rollups at ingest time (count, sum, avg per time window) | ClickHouse Materialized Views, Druid rollup, Flink windowed aggregations | Write to AggregatingMergeTree or SummingMergeTree target tables |
| OLAP Storage | Serves low-latency analytical queries | ClickHouse, Apache Druid, TimescaleDB, Apache Pinot | Column-oriented storage; horizontal sharding by time range |
| Query Cache | Caches frequently requested dashboard queries | Redis, Memcached | TTL = dashboard refresh interval; invalidate on new data window |
| Push Delivery | Streams updates to connected clients | WebSocket (Socket.IO, ws), Server-Sent Events (SSE) | Horizontal via sticky sessions + Redis PubSub fan-out |
| API Gateway | Rate limiting, auth, request routing | Kong, AWS API Gateway, Nginx | Per-client rate limits; separate read/write paths |
| Dashboard Frontend | Renders charts, tables, and metrics | Grafana, Apache Superset, custom React/D3.js | CDN for static assets; lazy-load panels not in viewport |
| Time-Series Index | Partitions data by time for fast range queries | Native time partitioning (ClickHouse, TimescaleDB) | Auto-drop or archive partitions older than retention window |
| Alerting Engine | Fires alerts when metrics cross thresholds | Grafana Alerting, PagerDuty, custom Flink CEP | Decouple from dashboard; process alert rules on the stream processor |
| Backfill Pipeline | Replays historical data for corrections or new metrics | Kafka replay from offset, batch re-ingestion | Run during off-peak; separate compute from real-time pipeline |
| Monitoring & Observability | Tracks pipeline health: lag, throughput, errors | Prometheus + Grafana, Datadog | Alert on consumer lag > 30s and ingestion error rate > 0.1% |

## Decision Tree

> Full script: [decision-tree.txt](scripts/decision-tree.txt) (36 lines)

```
START
|-- Dashboard update frequency?
|   |-- Sub-second (<1s latency)?
|   |   |-- Use WebSocket push + in-memory pre-aggregation (Flink stateful operators)
|   |   |-- Storage: ClickHouse or Druid with real-time ingestion
# ... (see full script)
```

## Step-by-Step Guide

### 1. Define metrics and SLAs

Identify which metrics the dashboard must display and their latency requirements. Separate metrics into tiers: Tier 1 (sub-second, e.g., live error rate), Tier 2 (near-real-time, e.g., revenue per minute), Tier 3 (periodic, e.g., daily active users). [src7]

```
Metric inventory example:
- requests_per_second    -> Tier 1 (sub-second)    -> WebSocket push
- error_rate_5min        -> Tier 2 (near-real-time) -> SSE, 5s refresh
- revenue_today          -> Tier 2 (near-real-time) -> SSE, 10s refresh
- daily_active_users     -> Tier 3 (periodic)       -> API poll, 5min cache
- p99_latency_1h         -> Tier 2 (near-real-time) -> SSE, 30s refresh
```

**Verify**: Every metric has a defined SLA (latency target) and delivery mechanism.

### 2. Set up the event ingestion pipeline

Deploy Kafka (or Kinesis) as the central event bus. Producers emit structured events with timestamps. [src4]

```bash
# Create Kafka topic with sufficient partitions for throughput
kafka-topics.sh --create \
  --bootstrap-server kafka:9092 \
  --topic raw-events \
  --partitions 12 \
  --replication-factor 3 \
  --config retention.ms=604800000  # 7 days retention

# Verify topic configuration
kafka-topics.sh --describe \
  --bootstrap-server kafka:9092 \
  --topic raw-events
```

**Verify**: `kafka-consumer-groups.sh --describe --group dashboard-consumer` shows partitions assigned and lag near zero.

### 3. Build pre-aggregation with materialized views

Create materialized views in ClickHouse that compute rollups at insert time, not query time. [src2]

```sql
-- Source table: raw events
CREATE TABLE raw_events (
    event_id     UInt64,
    event_type   LowCardinality(String),
    user_id      UInt64,
    value        Float64,
    ts           DateTime64(3),
    region       LowCardinality(String)
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(ts)
ORDER BY (event_type, ts);

-- Materialized view: 1-minute aggregations
CREATE MATERIALIZED VIEW mv_metrics_1min
ENGINE = AggregatingMergeTree()
PARTITION BY toYYYYMMDD(window_start)
ORDER BY (event_type, region, window_start)
AS SELECT
    event_type,
    region,
    toStartOfMinute(ts) AS window_start,
    countState()        AS event_count,
    sumState(value)     AS value_sum,
    avgState(value)     AS value_avg,
    maxState(value)     AS value_max,
    minState(value)     AS value_min
FROM raw_events
GROUP BY event_type, region, window_start;

-- Query the aggregated view (sub-second response)
SELECT
    event_type,
    region,
    window_start,
    countMerge(event_count)  AS count,
    sumMerge(value_sum)      AS total,
    avgMerge(value_avg)      AS average
FROM mv_metrics_1min
WHERE window_start >= now() - INTERVAL 1 HOUR
GROUP BY event_type, region, window_start
ORDER BY window_start DESC;
```

**Verify**: `SELECT count() FROM mv_metrics_1min` grows as data is inserted into `raw_events`. Query latency on the view should be <100ms.

### 4. Implement the WebSocket push service

Build a server that subscribes to aggregated results and pushes updates to connected dashboard clients. [src3] [src5]

```javascript
// Node.js WebSocket server using ws@8.16
const WebSocket = require('ws');         // ws@8.16
const Redis = require('ioredis');        // ioredis@5.3

const wss = new WebSocket.Server({ port: 8080 });
const redisSub = new Redis({ host: 'redis', port: 6379 });

// Track connected clients by dashboard/metric subscription
const subscriptions = new Map(); // metric_key -> Set<ws>

wss.on('connection', (ws) => {
    ws.on('message', (msg) => {
        const { action, metric } = JSON.parse(msg);
        if (action === 'subscribe') {
            if (!subscriptions.has(metric)) subscriptions.set(metric, new Set());
            subscriptions.get(metric).add(ws);
        }
    });
    ws.on('close', () => {
        for (const subs of subscriptions.values()) subs.delete(ws);
    });
});

// Fan out updates from Redis PubSub to WebSocket clients
redisSub.subscribe('metric-updates');
redisSub.on('message', (channel, message) => {
    const update = JSON.parse(message);
    const clients = subscriptions.get(update.metric) || new Set();
    const payload = JSON.stringify(update);
    for (const ws of clients) {
        if (ws.readyState === WebSocket.OPEN) ws.send(payload);
    }
});

console.log('WebSocket dashboard server on :8080');
```

**Verify**: Connect a WebSocket client and subscribe to a metric. Publish a test message to `metric-updates` in Redis. The client should receive it within <50ms.

### 5. Configure the dashboard frontend

Connect the frontend to the WebSocket server and render updates incrementally. [src3]

```javascript
// Browser-side: React hook for real-time metric subscription
function useRealtimeMetric(metricKey) {
    const [data, setData] = React.useState([]);
    const wsRef = React.useRef(null);

    React.useEffect(() => {
        const ws = new WebSocket('wss://dashboard-api.example.com/ws');
        wsRef.current = ws;

        ws.onopen = () => {
            ws.send(JSON.stringify({ action: 'subscribe', metric: metricKey }));
        };
        ws.onmessage = (event) => {
            const update = JSON.parse(event.data);
            setData(prev => {
                const next = [...prev, update].slice(-300); // Keep last 300 points
                return next;
            });
        };
        ws.onclose = () => {
            // Reconnect with exponential backoff
            setTimeout(() => wsRef.current = new WebSocket(ws.url), 2000);
        };

        return () => ws.close();
    }, [metricKey]);

    return data;
}
```

**Verify**: Open the dashboard in two browsers; both should display the same live data within 1 second of each other.

### 6. Set up retention and partition management

Automate old partition removal to keep query performance stable. [src1]

```sql
-- ClickHouse: drop partitions older than 90 days
ALTER TABLE raw_events DROP PARTITION '20251101';

-- Automate with a cron job or ClickHouse TTL
ALTER TABLE raw_events
    MODIFY TTL ts + INTERVAL 90 DAY DELETE;

-- For aggregated tables, keep longer (1 year)
ALTER TABLE mv_metrics_1min
    MODIFY TTL window_start + INTERVAL 365 DAY DELETE;
```

**Verify**: `SELECT partition, rows FROM system.parts WHERE table = 'raw_events'` should show no partitions older than the retention window.

## Code Examples
<!-- Keep inline examples <=15 lines. For longer scripts, extract to scripts/ subdirectory
     and link: "Full script: [name.ext](scripts/name.ext) (N lines)" -->

### Python: WebSocket Dashboard Client with Auto-Reconnect

```python
# Input:  WebSocket URL and metric key to subscribe to
# Output: Prints real-time metric updates to stdout

import asyncio
import json
import websockets  # websockets@12.0

async def subscribe_metric(ws_url: str, metric: str):
    while True:
        try:
            async with websockets.connect(ws_url) as ws:
                await ws.send(json.dumps({"action": "subscribe", "metric": metric}))
                async for message in ws:
                    update = json.loads(message)
                    print(f"[{update['timestamp']}] {metric}: {update['value']}")
        except websockets.ConnectionClosed:
            print("Connection lost, reconnecting in 2s...")
            await asyncio.sleep(2)

asyncio.run(subscribe_metric("wss://dashboard-api.example.com/ws", "requests_per_second"))
```

### SQL: ClickHouse Materialized View Refresh Pattern

```sql
-- Input:  Continuous inserts into raw_events table
-- Output: Pre-aggregated 1-minute windows, queryable in <50ms

-- Step 1: Insert data (from Kafka engine or application)
INSERT INTO raw_events VALUES
    (1, 'page_view', 42, 1.0, now(), 'us-east'),
    (2, 'purchase',  42, 99.99, now(), 'us-east');

-- Step 2: Query the materialized view (auto-updated on insert)
SELECT event_type, countMerge(event_count) AS cnt,
       avgMerge(value_avg) AS avg_val
FROM mv_metrics_1min
WHERE window_start >= now() - INTERVAL 5 MINUTE
GROUP BY event_type;
```

### Go: SSE Server for Dashboard Updates

> Full script: [go-sse-server-for-dashboard-updates.go](scripts/go-sse-server-for-dashboard-updates.go) (32 lines)

```go
// Input:  HTTP request to /events?metric=requests_per_second
// Output: Server-Sent Events stream with real-time metric updates
package main
import (
    "fmt"
# ... (see full script)
```

## Anti-Patterns

### Wrong: Querying raw events directly from the dashboard

```sql
-- BAD -- every dashboard refresh runs a full scan on billions of rows
SELECT event_type, COUNT(*) AS cnt, AVG(value) AS avg_val
FROM raw_events
WHERE ts >= now() - INTERVAL 1 HOUR
GROUP BY event_type;
-- At 1M events/sec, this table has 3.6B rows/hour
-- Query takes 5-30 seconds, competes with ingestion for I/O
-- 100 concurrent viewers = 100 parallel full scans = cluster death
```

### Correct: Query pre-aggregated materialized views

```sql
-- GOOD -- queries a compact rollup table, not raw events
SELECT event_type, countMerge(event_count) AS cnt,
       avgMerge(value_avg) AS avg_val
FROM mv_metrics_1min
WHERE window_start >= now() - INTERVAL 1 HOUR
GROUP BY event_type;
-- Reads ~60 rows (one per minute) instead of 3.6B
-- Sub-100ms response, no impact on ingestion
-- Scales to thousands of concurrent dashboard viewers
```

### Wrong: Polling the database on every client refresh

```javascript
// BAD -- every dashboard client polls the API, which queries the DB
setInterval(async () => {
    const res = await fetch('/api/metrics?range=1h');
    updateChart(await res.json());
}, 1000);
// 1000 clients * 1 req/sec = 1000 DB queries/sec
// Each query re-computes the same result
// Database becomes the bottleneck; latency spikes
```

### Correct: Compute once, push to all clients via WebSocket/SSE

```javascript
// GOOD -- server computes once, broadcasts to all subscribers
const ws = new WebSocket('wss://dashboard-api.example.com/ws');
ws.onopen = () => ws.send(JSON.stringify({ action: 'subscribe', metric: 'rps' }));
ws.onmessage = (e) => updateChart(JSON.parse(e.data));
// Server queries DB once per interval (e.g., every 5s)
// Pushes result to all 1000 connected clients via pub/sub
// DB load: 1 query/5s instead of 1000 queries/sec
```

### Wrong: Storing all metrics in a single unpartitioned table

```sql
-- BAD -- no partitioning means full table scan for time-range queries
CREATE TABLE metrics (
    id BIGINT PRIMARY KEY,
    metric_name VARCHAR(255),
    value DOUBLE,
    recorded_at TIMESTAMP
);
-- After 6 months: billions of rows in one table
-- WHERE recorded_at > now() - INTERVAL 1 HOUR scans the entire table
-- Index bloat, vacuum pauses, degraded insert performance
```

### Correct: Partition by time range

```sql
-- GOOD -- ClickHouse: automatic time-based partitioning
CREATE TABLE metrics (
    metric_name LowCardinality(String),
    value       Float64,
    recorded_at DateTime64(3)
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(recorded_at)
ORDER BY (metric_name, recorded_at);
-- Queries only touch relevant partitions
-- Old partitions can be dropped instantly: ALTER TABLE metrics DROP PARTITION '20250101'
-- Insert and query paths don't interfere
```

### Wrong: Synchronous processing in the ingestion path

```python
# BAD -- blocking processing delays event ingestion
def handle_event(event):
    save_to_database(event)           # 5-10ms
    compute_aggregation(event)         # 20-50ms
    notify_all_dashboards(event)       # 10-100ms (depends on client count)
    update_alert_rules(event)          # 5-20ms
    # Total: 40-180ms per event
    # At 100K events/sec, this creates a massive backlog
```

### Correct: Async pipeline with decoupled stages

```python
# GOOD -- each stage runs independently, connected by message queues
def handle_event(event):
    kafka_producer.send('raw-events', event)  # <1ms, non-blocking
    # Done. Everything else happens asynchronously:
    # Flink reads raw-events -> computes aggregations -> writes to ClickHouse
    # Separate service reads aggregations -> pushes to WebSocket clients
    # Alert engine reads raw-events -> evaluates rules -> fires alerts
    # Each stage scales independently
```

## Common Pitfalls

- **Consumer lag snowball**: If the stream processor falls behind, lag compounds and dashboards show increasingly stale data. Fix: set up alerts on consumer lag (`kafka-consumer-groups.sh --describe`); auto-scale Flink task managers when lag exceeds 30 seconds. [src4]
- **Materialized view sync delay**: ClickHouse materialized views process inserts synchronously per batch; large batches create visible lag. Fix: tune `max_insert_block_size` to balance throughput and freshness; use smaller, more frequent batches (e.g., 10K rows every 1s rather than 100K every 10s). [src2]
- **WebSocket connection storms**: When the server restarts, all clients reconnect simultaneously, overloading the server. Fix: implement exponential backoff with jitter on the client side (`delay = min(base * 2^attempt, max_delay) + random(0, jitter)`). [src5]
- **High-cardinality group-by explosion**: Grouping by user_id or session_id in materialized views creates millions of aggregation buckets, consuming excessive memory. Fix: only pre-aggregate by low-cardinality dimensions (event_type, region, status_code); handle high-cardinality queries via on-demand OLAP queries with LIMIT. [src1]
- **No backpressure handling**: When downstream systems (ClickHouse, dashboard) slow down, the pipeline queues grow unboundedly and eventually OOM. Fix: configure Kafka consumer `max.poll.records` and Flink checkpointing to create natural backpressure; use bounded queues between stages. [src4]
- **Dashboard query amplification**: Each panel on a dashboard fires an independent query; a dashboard with 20 panels = 20 separate queries per refresh. Fix: batch-fetch all panel data in a single API call, or use a query multiplexer that deduplicates identical time-range queries. [src7]
- **Timezone-naive aggregation**: Aggregating by `toStartOfDay()` without specifying timezone produces wrong results for users in different zones. Fix: store all timestamps in UTC; apply timezone conversion at the display layer, not the aggregation layer. [src2]
- **Missing data gap handling**: Dashboard charts show misleading interpolated lines when data is missing (outage, backfill delay). Fix: emit explicit zero/null heartbeat events; render gaps as breaks in the chart, not smooth interpolation. [src3]

## Diagnostic Commands

```bash
# Check Kafka consumer lag for the dashboard pipeline
kafka-consumer-groups.sh --bootstrap-server kafka:9092 \
  --describe --group dashboard-aggregator

# Monitor ClickHouse insert and query performance
clickhouse-client --query "SELECT query_duration_ms, read_rows, result_rows \
  FROM system.query_log WHERE type = 'QueryFinish' \
  ORDER BY event_time DESC LIMIT 20"

# Check ClickHouse materialized view status
clickhouse-client --query "SELECT database, table, engine, total_rows, \
  total_bytes FROM system.tables WHERE engine LIKE '%MergeTree%'"

# Monitor WebSocket connection count
curl -s http://dashboard-ws:8080/healthz | jq '.active_connections'

# Check partition sizes and retention compliance
clickhouse-client --query "SELECT partition, table, rows, \
  formatReadableSize(bytes_on_disk) AS size \
  FROM system.parts WHERE table = 'raw_events' \
  ORDER BY partition DESC LIMIT 20"

# Verify Redis PubSub fan-out is working
redis-cli PUBSUB NUMSUB metric-updates
```

## Version History & Compatibility

| Technology | Version | Status | Notes |
|---|---|---|---|
| Apache Kafka | 3.6-3.8 | Current | KRaft mode (no Zookeeper) is production-ready since 3.6 |
| ClickHouse | 24.x-25.x | Current | Materialized views with AggregatingMergeTree stable since 22.x |
| Apache Druid | 30.x | Current | Multi-stage query engine since 28.0 improves join performance |
| Apache Flink | 1.19-1.20 | Current | Unified batch/stream; state backend improvements in 1.19 |
| Grafana | 11.x | Current | Grafana Live (WebSocket push) stable since 8.0; HA requires Redis |
| TimescaleDB | 2.15+ | Current | Continuous aggregates (materialized views) stable since 2.0 |
| WebSocket API | RFC 6455 | Stable | Universal browser support; HTTP/2 compatible via upgrade |
| Server-Sent Events | W3C spec | Stable | Native browser support; auto-reconnect built-in; HTTP/2 multiplexed |

## When to Use / When Not to Use

| Use When | Don't Use When | Use Instead |
|---|---|---|
| Metrics must update in <30 seconds for operational decisions | Reports are generated once per day or week | Traditional data warehouse (Snowflake, BigQuery) |
| Monitoring live systems (error rates, throughput, latency) | Historical analysis over months/years of data | Batch analytics with scheduled queries |
| Thousands of concurrent viewers need the same live data | Each user needs a unique, complex ad-hoc query | OLAP query engine with per-user caching |
| Event-driven architecture already produces a Kafka/Kinesis stream | Data arrives in batch files (CSV uploads, ETL dumps) | Batch ingestion pipeline + scheduled dashboard refresh |
| Alerting on metric thresholds must fire within seconds | Accuracy matters more than speed (financial reconciliation) | Double-entry batch pipeline with reconciliation step |

## Important Caveats

- Lambda architecture (separate batch + stream layers) provides more accurate historical data but doubles operational complexity. Kappa architecture (stream-only) is simpler but struggles with complex historical reprocessing. Most production dashboards use a hybrid: Kappa for real-time + periodic batch correction jobs. [src6]
- ClickHouse materialized views are trigger-based (fire on INSERT to source table); they do not handle UPDATE or DELETE automatically. If your source data requires corrections, use a ReplacingMergeTree source table and rebuild affected materialized view partitions. [src2]
- Grafana Live (WebSocket push) supports 100 concurrent connections per instance by default; for >100 viewers, you must deploy Grafana in HA mode with Redis as the Live HA engine. [src3]
- Pre-aggregation at ingest time trades query flexibility for speed. If the business adds a new metric dimension after deployment, you may need to rebuild materialized views and replay historical data from Kafka. Plan retention accordingly (7-30 day Kafka retention for replay). [src4]
- Server-Sent Events (SSE) are unidirectional (server-to-client only) and work over standard HTTP. For dashboards that only display data (no user input beyond initial subscription), SSE is simpler than WebSocket and supports automatic reconnection natively. [src5]

## Related Units

- [URL Shortener System Design](/software/system-design/url-shortener/2026)
- [Social Media Feed System Design](/software/system-design/social-media-feed/2026)
- [Rate Limiter System Design](/software/system-design/rate-limiter/2026)
