Web Engineering ·

Building Real-Time Dashboards with Server-Sent Events: Live Metrics, Chart Updates, and Connection Recovery in Next.js

A practical guide to streaming live metrics into a Next.js dashboard using SSE Route Handlers, efficient chart re-rendering without full redraws, missed-event recovery, and Redis pub/sub fan-out for multi-instance deployments.

Building Real-Time Dashboards with Server-Sent Events: Live Metrics, Chart Updates, and Connection Recovery in Next.js

Most dashboards are built as polling loops: the client fires a request every five seconds, waits for a full response, rerenders the entire chart, and does it again. At low traffic this is invisible. At scale, or when you need sub-second latency, it creates unnecessary load on your API, visible chart flicker on every refresh cycle, and missed state changes that fall between polling intervals.

Server-Sent Events are a better fit for this pattern. The server holds a connection open and pushes metric updates as they are ready. The client applies incremental patches rather than full replacements. Reconnection is handled by the browser’s EventSource implementation. And because SSE runs over ordinary HTTP, every proxy, CDN, and load balancer that knows HTTP already handles it.

This article focuses on the dashboard-specific problems that a general SSE guide does not cover: streaming pre-aggregated metrics from Next.js App Router Route Handlers, preventing chart libraries from triggering expensive full redraws on every event, recovering missed events after a connection drop, and scaling connections across multiple server instances with a Redis pub/sub fan-out.

Why SSE Over WebSockets for Dashboards

The distinguishing question for dashboard use cases is direction of data flow. A dashboard reads: the server pushes metric updates, the client renders them. The client never needs to send data back on the same connection. WebSockets are bidirectional by design, and that bidirectionality comes with real costs: a separate upgrade handshake, sticky session requirements from the start, application-level heartbeat implementation, and proxy support that varies by infrastructure provider.

SSE is unidirectional by design. For dashboards, that is not a limitation; it is a match.

DimensionSSEWebSockets
DirectionServer to clientBidirectional
ProtocolHTTP (no upgrade)TCP upgrade
Browser reconnectAutomatic (built-in)Manual implementation
Proxy/CDN supportBroad, HTTP-nativeInconsistent
Sticky sessions (HTTP/1.1)YesYes
HTTP/2 multiplexingYes (connections share one TCP)No
DebuggingNetwork tab, readable textOpaque frames
Auth modelStandard HTTP headers, cookiesCustom (cookies or initial message)
Good forFeeds, metrics, dashboards, logsChat, collaborative editing, games

The one WebSocket scenario that is genuinely hard to replicate with SSE is a dashboard that also lets users send commands, like adjusting time ranges or triggering actions, on the same low-latency channel. For read-only dashboards, SSE is strictly simpler to operate.

SSE Route Handlers in Next.js App Router

Next.js App Router exposes Route Handlers in app/api/*/route.ts. For SSE, you return a Response wrapping a ReadableStream. The key constraint is that Next.js does not expose a streaming helper like Hono’s streamSSE; you construct the stream manually.

// app/api/metrics/stream/route.ts
import { NextRequest } from "next/server";
import { getMetricsSubscriber } from "@/lib/metrics-subscriber";

export const runtime = "nodejs"; // SSE requires a persistent connection; edge runtime limits apply.
export const dynamic = "force-dynamic"; // Disable caching for this route.

export async function GET(request: NextRequest) {
  const dashboardId = request.nextUrl.searchParams.get("dashboardId");
  const lastEventId = request.headers.get("Last-Event-ID");

  if (!dashboardId) {
    return new Response("Missing dashboardId", { status: 400 });
  }

  // Authenticate once on connect. SSE is a single long-lived HTTP request.
  const session = await validateSession(request);
  if (!session) {
    return new Response("Unauthorized", { status: 401 });
  }

  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      // Replay missed events if client reconnected with a Last-Event-ID.
      if (lastEventId) {
        const missed = await getMissedMetrics(dashboardId, lastEventId);
        for (const event of missed) {
          controller.enqueue(encoder.encode(formatSSE(event)));
        }
      } else {
        // New connection: send current state snapshot so the chart renders immediately.
        const snapshot = await getMetricsSnapshot(dashboardId);
        controller.enqueue(
          encoder.encode(
            formatSSE({ id: "snapshot", event: "metrics-snapshot", data: snapshot })
          )
        );
      }

      // Set client reconnect interval to 10 seconds.
      controller.enqueue(encoder.encode("retry: 10000\n\n"));

      const subscriber = getMetricsSubscriber(dashboardId);

      const send = (event: SSEEvent) => {
        try {
          controller.enqueue(encoder.encode(formatSSE(event)));
        } catch {
          // Controller is closed; subscriber cleanup runs in cancel() below.
        }
      };

      subscriber.on("metric", send);

      // Heartbeat every 20 seconds to prevent proxy timeout on idle dashboards.
      const heartbeat = setInterval(() => {
        try {
          controller.enqueue(encoder.encode(": heartbeat\n\n"));
        } catch {
          clearInterval(heartbeat);
        }
      }, 20_000);

      // Cleanup when client disconnects.
      request.signal.addEventListener("abort", () => {
        clearInterval(heartbeat);
        subscriber.off("metric", send);
        subscriber.release(dashboardId);
        controller.close();
      });
    },

    cancel() {
      // Called when the browser closes the connection.
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-store, no-cache",
      Connection: "keep-alive",
      "X-Accel-Buffering": "no", // Disable nginx response buffering.
    },
  });
}

interface SSEEvent {
  id: string;
  event: string;
  data: unknown;
}

function formatSSE(event: SSEEvent): string {
  return `id: ${event.id}\nevent: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`;
}

// Stubs — replace with your actual implementations.
async function validateSession(_req: NextRequest) {
  return { userId: "user_123" };
}

async function getMissedMetrics(_dashboardId: string, _afterId: string): Promise<SSEEvent[]> {
  return [];
}

async function getMetricsSnapshot(_dashboardId: string): Promise<unknown> {
  return {};
}

A few things that are specific to the Next.js context. The runtime = "nodejs" export is required. The default edge runtime imposes request timeouts that break long-lived SSE connections. Node.js runtime has no such limit. The dynamic = "force-dynamic" export prevents the router from caching the route response, which would deliver a stale stream to every client.

The request.signal abort listener is the correct cleanup hook in Next.js. When the client disconnects, the AbortSignal fires, giving you a deterministic place to deregister from the metrics subscriber and release resources.

Streaming Pre-Aggregated Metrics

Raw metric events (individual request timings, per-row database writes) are too high-frequency to stream directly. Instead, aggregate on the server before pushing to connected clients. A typical pattern is a background worker that accumulates metrics into fixed windows and publishes summaries.

// lib/metrics-aggregator.ts

interface MetricWindow {
  windowStart: number; // Unix timestamp, aligned to window boundary
  requestCount: number;
  errorCount: number;
  p50LatencyMs: number;
  p95LatencyMs: number;
  p99LatencyMs: number;
}

interface RawMetric {
  latencyMs: number;
  isError: boolean;
  timestamp: number;
}

export class MetricsAggregator {
  private buffer: RawMetric[] = [];
  private windowSizeMs: number;
  private onWindow: (window: MetricWindow) => void;
  private timer: NodeJS.Timeout;

  constructor(windowSizeMs: number, onWindow: (window: MetricWindow) => void) {
    this.windowSizeMs = windowSizeMs;
    this.onWindow = onWindow;

    this.timer = setInterval(() => this.flush(), windowSizeMs);
  }

  record(metric: RawMetric) {
    this.buffer.push(metric);
  }

  private flush() {
    if (this.buffer.length === 0) return;

    const now = Date.now();
    const windowStart = now - (now % this.windowSizeMs);
    const latencies = this.buffer.map((m) => m.latencyMs).sort((a, b) => a - b);

    const window: MetricWindow = {
      windowStart,
      requestCount: this.buffer.length,
      errorCount: this.buffer.filter((m) => m.isError).length,
      p50LatencyMs: latencies[Math.floor(latencies.length * 0.5)] ?? 0,
      p95LatencyMs: latencies[Math.floor(latencies.length * 0.95)] ?? 0,
      p99LatencyMs: latencies[Math.floor(latencies.length * 0.99)] ?? 0,
    };

    this.buffer = [];
    this.onWindow(window);
  }

  destroy() {
    clearInterval(this.timer);
  }
}

Each completed window is published to connected dashboard clients as a single SSE event. The client receives a compact summary every N seconds (5 seconds is a common dashboard window) rather than a firehose of individual data points. This keeps the SSE connection payload small and the chart update frequency predictable.

Efficient Chart Re-Rendering: Avoiding Full Redraws

The default behavior when you receive a metric update and call a chart library’s setData() method is a full redraw: the library recomputes scales, layout, and all data series from scratch. On a dashboard with five or six chart panels and updates arriving every few seconds, this produces visible flickering and unnecessary CPU usage.

The solution is to push only incremental patches rather than full dataset replacements. Most chart libraries (Chart.js, Recharts, uPlot, Highcharts) expose a method to append a new data point and shift the window rather than rerender from scratch.

// hooks/useLiveMetrics.ts
import { useEffect, useRef, useCallback } from "react";

interface MetricWindow {
  windowStart: number;
  requestCount: number;
  errorCount: number;
  p50LatencyMs: number;
  p95LatencyMs: number;
  p99LatencyMs: number;
}

interface MetricsSnapshot {
  windows: MetricWindow[];
}

interface UseLiveMetricsOptions {
  dashboardId: string;
  maxDataPoints?: number;
  onUpdate: (windows: MetricWindow[]) => void;
}

export function useLiveMetrics({ dashboardId, maxDataPoints = 60, onUpdate }: UseLiveMetricsOptions) {
  const windowsRef = useRef<MetricWindow[]>([]);
  const sourceRef = useRef<EventSource | null>(null);

  const applySnapshot = useCallback(
    (snapshot: MetricsSnapshot) => {
      windowsRef.current = snapshot.windows.slice(-maxDataPoints);
      onUpdate([...windowsRef.current]);
    },
    [maxDataPoints, onUpdate]
  );

  const applyIncremental = useCallback(
    (window: MetricWindow) => {
      const windows = windowsRef.current;

      // Deduplicate by windowStart — the same window may arrive twice
      // if the server replays it during reconnection recovery.
      if (windows.length > 0 && windows[windows.length - 1].windowStart === window.windowStart) {
        // Replace in-place: this window was updated, not a new one.
        windows[windows.length - 1] = window;
      } else {
        windows.push(window);
        if (windows.length > maxDataPoints) {
          windows.shift(); // Drop the oldest point, slide the window.
        }
      }

      // onUpdate receives the same array reference — chart library must handle this.
      // For immutable chart libraries (Recharts), spread: [...windowsRef.current]
      onUpdate([...windowsRef.current]);
    },
    [maxDataPoints, onUpdate]
  );

  useEffect(() => {
    const url = new URL("/api/metrics/stream", window.location.origin);
    url.searchParams.set("dashboardId", dashboardId);

    const source = new EventSource(url.toString(), { withCredentials: true });
    sourceRef.current = source;

    source.addEventListener("metrics-snapshot", (e: MessageEvent) => {
      const snapshot = JSON.parse(e.data) as MetricsSnapshot;
      applySnapshot(snapshot);
    });

    source.addEventListener("metric-window", (e: MessageEvent) => {
      const window = JSON.parse(e.data) as MetricWindow;
      applyIncremental(window);
    });

    source.addEventListener("error", () => {
      // readyState CONNECTING (0): browser is retrying automatically.
      // readyState CLOSED (2): permanent close (e.g., 401 response).
      // No action needed for transient drops; EventSource handles reconnect.
    });

    return () => {
      source.close();
      sourceRef.current = null;
    };
  }, [dashboardId, applySnapshot, applyIncremental]);
}

The key pattern in applyIncremental is the deduplication check on windowStart. When the client reconnects and the server replays recent events using Last-Event-ID, some events may overlap with what the client already has in memory. Without deduplication, the chart shows doubled data points at the seam. This is a chart-specific bug that general SSE guides do not address.

For chart libraries that trigger a full redraw on any data change (most React-based chart libraries do), the trick is to use useRef to hold the data array and a separate state variable to trigger renders only when genuinely needed. If the chart renders on every SSE event and each render is expensive, add a render throttle:

const renderScheduledRef = useRef(false);

const scheduleRender = useCallback(() => {
  if (renderScheduledRef.current) return;
  renderScheduledRef.current = true;
  requestAnimationFrame(() => {
    renderScheduledRef.current = false;
    onUpdate([...windowsRef.current]);
  });
}, [onUpdate]);

This coalesces multiple metric events arriving in the same animation frame into a single render pass, which matters when your aggregation window is very short (under one second) or when multiple chart panels share the same SSE stream.

Connection Recovery and Missed-Event Handling

The browser’s EventSource reconnects automatically after a network drop, sending Last-Event-ID with the ID of the last event it received. The server replays events after that ID. For a dashboard, this means the chart catches up on the windows it missed during the drop.

The server-side replay query requires durable event storage with a short TTL. A Redis stream is a natural fit because it provides ordered event storage, trimming by length or age, and range queries by ID.

// lib/event-store.ts
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);

interface MetricEvent {
  id: string;
  event: string;
  data: string; // pre-serialized JSON
}

export async function persistMetricEvent(
  dashboardId: string,
  event: Omit<MetricEvent, "id">
): Promise<string> {
  // XADD returns the auto-generated stream entry ID.
  const id = await redis.xadd(
    `dashboard:${dashboardId}:events`,
    "MAXLEN",
    "~",
    300, // Keep last ~300 events per dashboard (5 minutes at 1 event/sec).
    "*",
    "event",
    event.event,
    "data",
    event.data
  );
  return id!;
}

export async function getMissedEvents(
  dashboardId: string,
  afterId: string
): Promise<MetricEvent[]> {
  // XRANGE returns entries with IDs greater than afterId.
  const entries = await redis.xrange(
    `dashboard:${dashboardId}:events`,
    `(${afterId}`, // Exclusive start.
    "+"
  );

  return entries.map(([id, fields]) => ({
    id,
    event: fields[fields.indexOf("event") + 1],
    data: fields[fields.indexOf("data") + 1],
  }));
}

One detail specific to dashboards: if the client was disconnected for longer than your event TTL (the Redis stream was trimmed), replaying Last-Event-ID will return no events. In this case, the correct behavior is to send a fresh snapshot instead. The server can detect this by checking whether the afterId falls before the earliest available entry in the stream.

export async function getReplayOrSnapshot(
  dashboardId: string,
  lastEventId: string | null
): Promise<{ type: "replay"; events: MetricEvent[] } | { type: "snapshot"; data: unknown }> {
  if (!lastEventId) {
    return { type: "snapshot", data: await getMetricsSnapshot(dashboardId) };
  }

  const missed = await getMissedEvents(dashboardId, lastEventId);

  // If no missed events and lastEventId is older than our stream, send snapshot.
  if (missed.length === 0) {
    const info = await redis.xinfo("STREAM", `dashboard:${dashboardId}:events`);
    // Parse the first entry ID from xinfo response to check if lastEventId precedes it.
    // Simplified: treat empty replay as stale and fall back to snapshot.
    return { type: "snapshot", data: await getMetricsSnapshot(dashboardId) };
  }

  return { type: "replay", events: missed };
}

The client does not need to distinguish between a replay and a snapshot explicitly. If it receives a metrics-snapshot event, it replaces the full dataset. If it receives metric-window events, it applies them incrementally. This keeps the client state machine simple.

Scaling SSE Connections with Redis Pub/Sub Fan-Out

A single Next.js server instance can hold metric subscriptions in memory, but the moment you run more than one instance (any production deployment with a load balancer), events published from one instance need to reach clients connected to other instances.

The standard pattern is a Redis pub/sub fan-out layer. Each server instance subscribes to the dashboards that have active local connections. When a metric window is ready to publish, it goes to Redis pub/sub and all instances relay it to their local connections.

// lib/metrics-subscriber.ts
import Redis from "ioredis";
import { EventEmitter } from "events";

const publisher = new Redis(process.env.REDIS_URL!);
const subscriber = new Redis(process.env.REDIS_URL!);

// Local registry: dashboardId -> set of send callbacks.
const localConnections = new Map<string, Set<(event: SSEEvent) => void>>();
const emitter = new EventEmitter();
emitter.setMaxListeners(0); // Dashboards with many concurrent viewers.

subscriber.on("pmessage", (_pattern: string, channel: string, message: string) => {
  const dashboardId = channel.replace("dashboard:", "").replace(":metrics", "");
  emitter.emit(dashboardId, JSON.parse(message));
});

interface SSEEvent {
  id: string;
  event: string;
  data: unknown;
}

export function getMetricsSubscriber(dashboardId: string) {
  // Subscribe to Redis channel for this dashboard if this is the first local connection.
  if (!localConnections.has(dashboardId)) {
    localConnections.set(dashboardId, new Set());
    subscriber.psubscribe(`dashboard:${dashboardId}:metrics`);
  }

  const send = (event: SSEEvent) => {
    // Delivered by the emitter below.
  };

  return {
    on: (eventName: string, callback: (event: SSEEvent) => void) => {
      emitter.on(dashboardId, callback);
      localConnections.get(dashboardId)?.add(callback);
    },
    off: (_eventName: string, callback: (event: SSEEvent) => void) => {
      emitter.off(dashboardId, callback);
      localConnections.get(dashboardId)?.delete(callback);
    },
    release: (dashboardId: string) => {
      if (localConnections.get(dashboardId)?.size === 0) {
        localConnections.delete(dashboardId);
        subscriber.punsubscribe(`dashboard:${dashboardId}:metrics`);
      }
    },
  };
}

export async function publishMetricWindow(dashboardId: string, event: SSEEvent) {
  const id = await persistMetricEvent(dashboardId, {
    event: event.event,
    data: JSON.stringify(event.data),
  });
  event.id = id;
  await publisher.publish(`dashboard:${dashboardId}:metrics`, JSON.stringify(event));
}

The subscriber and publisher must be separate Redis connections. A connection in subscribe mode cannot execute other commands.

Production Considerations

Memory leaks from orphaned connections. The most common SSE production bug is a connection that is no longer active but was never cleaned up: the client disconnected without triggering the abort signal (a browser crash, a network reset with no RST packet). Track active connection count per dashboard as a metric and alert when it grows unbounded. A watchdog that pings connections every 60 seconds with a heartbeat and closes any that fail to enqueue without error (because the underlying socket is gone) prevents the leak from accumulating over days.

Connection limits at scale. Each SSE connection is one open file descriptor and one socket buffer (roughly 4-8KB). A Next.js process running on a small instance with the default ulimit of 1024 file descriptors will exhaust its descriptor limit at around 900 concurrent connections, well before it runs out of memory or CPU. Raise ulimit -n to at least 65536 in production. Monitor the current file descriptor count as part of your process health metrics.

Load balancer timeouts. Most load balancers (AWS ALB, nginx, GCP HTTPS LB) have default idle timeouts of 60 seconds. An SSE connection with no active metrics (a dashboard that nobody is viewing, a low-traffic service) will be closed by the load balancer before the heartbeat fires if the heartbeat interval exceeds the idle timeout. Set the heartbeat interval to 20-25 seconds for an ALB with a 60-second idle timeout, or increase the load balancer timeout to 3600 seconds for connections you expect to be long-lived.

For nginx as a reverse proxy in front of Next.js, disable response buffering for the SSE route:

location /api/metrics/stream {
  proxy_pass http://nextjs_upstream;
  proxy_buffering off;
  proxy_cache off;
  proxy_read_timeout 3600s;
  proxy_set_header Connection '';
  proxy_http_version 1.1;
}

The X-Accel-Buffering: no header in the SSE response already disables nginx buffering programmatically, but the explicit nginx config is more reliable and avoids relying on the header surviving through intermediate layers.

Tradeoffs at scale: SSE connection counts vs. polling. SSE holds connections open continuously. Polling creates N connections per minute but closes each one. The crossover point depends on your connection duration and request overhead. For dashboards where viewers keep a tab open for 30+ minutes, SSE consumes fewer resources than polling because there is no repeated TCP handshake and TLS negotiation overhead. For dashboards viewed for under 2-3 minutes (embedded widgets, one-off checks), polling may be cheaper on total connection overhead.

ApproachLatencyServer connectionsClient complexityInfrastructure notes
SSE (this article)Near real-time1 persistent per viewerLow (EventSource built-in)Requires buffering disabled, fd limits raised
Polling (5s interval)0-5s lagMany short-livedLowSimple, works everywhere
WebSocketsNear real-time1 persistent per viewerMedium (manual reconnect)Sticky sessions required
Long polling0-2s lag1 per outstanding requestMediumWorks behind restrictive proxies

Graceful shutdown on deploy. When Next.js restarts (a new deployment), open SSE connections are aborted abruptly. Send a final SSE event with event: server-restart before closing each connection, so clients can schedule a reconnect with a short delay rather than all hammering the reconnect endpoint simultaneously.

// Called in a SIGTERM handler registered in your Next.js instrumentation.ts
import { getAllActiveSubscribers } from "@/lib/metrics-subscriber";

export async function flushConnectionsOnShutdown() {
  const subscribers = getAllActiveSubscribers();
  for (const [dashboardId, callbacks] of subscribers) {
    for (const send of callbacks) {
      send({ id: "shutdown", event: "server-restart", data: { retryAfterMs: 3000 } });
    }
  }
  await new Promise((resolve) => setTimeout(resolve, 2000));
}

The Right Mental Model

SSE is not a real-time protocol so much as a consequence of holding an HTTP response open longer than usual. Every piece of infrastructure that handles HTTP already handles it. The dashboard-specific work is mostly about state management: what to send on first connect, how to recover missed windows on reconnect, and how to push incremental updates to charts without triggering expensive redraws on every event.

The protocol complexity is low. The operational complexity (file descriptor limits, load balancer timeouts, fan-out at scale) is manageable with the patterns above. For read-only real-time dashboards, this is a more maintainable architecture than WebSockets at every scale short of millions of concurrent connections.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.