React Realtime: Integrating WebSocket and Mercure
AI generated
</>
{ }
React · WebSocket · Mercure · Realtime
React Realtime:
Integrating WebSocket and Mercure

Polling is not a realtime architecture, it is a compromise that collapses under load. WebSocket and Mercure offer genuine push communication, but integrating them into React requires well thought out custom hooks, robust reconnect logic and secure state management for realtime data streams.

16 min read WebSocket · Mercure · SSE · Custom Hooks · Reconnect · Authentication React 18+ · TypeScript · Mercure Hub

1. WebSocket vs. SSE vs. long-polling: choosing the right approach

The decision between WebSocket, Server-Sent Events (SSE) and long-polling depends on the direction of communication and your infrastructure requirements. WebSocket is a bidirectional, full-duplex protocol, the client can send and receive messages at any time. That makes it ideal for chat applications, collaborative tools and gaming, where both sides actively communicate. WebSocket does, however, require a dedicated server that manages persistent connections, and it does not work reliably across every proxy configuration.

Server-Sent Events are unidirectional: the server pushes data, the client can only respond via normal HTTP requests. That sounds like a drawback, but it is sufficient for most realtime use cases: notifications, live updates, dashboards, order status tracking. SSE uses plain HTTP and therefore works through every proxy and load balancer without special configuration. Mercure builds on top of SSE and adds a complete hub concept with topic based routing and JWT authentication.

2. useWebSocket: a robust custom hook

The naivest WebSocket implementation in React opens the connection directly inside a useEffect and forgets to close it on unmount, to reconnect on network errors, or to prevent duplicate connections caused by StrictMode's double mounts. A robust useWebSocket hook encapsulates all of that logic and exposes the component only a clean API: connection status, received messages and a send function.

The hook must be compatible with React StrictMode, which runs every effect twice in development mode. That means the WebSocket connection is opened, immediately closed again and then reopened. The solution is a cleanup in the effect's return function together with a reference to the current connection instance. useRef holds the WebSocket instance without triggering a re-render. useState holds only the data relevant to the UI: connection status and the last message.


// useWebSocket.ts: Robust WebSocket hook with cleanup and status tracking
import { useCallback, useEffect, useRef, useState } from 'react';

type WsStatus = 'connecting' | 'open' | 'closed' | 'error';

interface UseWebSocketReturn {
  status: WsStatus;
  lastMessage: MessageEvent | null;
  send: (data: string | ArrayBuffer | Blob) => void;
}

export function useWebSocket(url: string): UseWebSocketReturn {
  const wsRef = useRef<WebSocket | null>(null);
  const [status, setStatus] = useState<WsStatus>('connecting');
  const [lastMessage, setLastMessage] = useState<MessageEvent | null>(null);

  useEffect(() => {
    const ws = new WebSocket(url);
    wsRef.current = ws;
    setStatus('connecting');

    ws.onopen = () => setStatus('open');
    ws.onmessage = (event) => setLastMessage(event);
    ws.onerror = () => setStatus('error');
    ws.onclose = () => setStatus('closed');

    // Cleanup: close connection when component unmounts or URL changes
    return () => {
      ws.close(1000, 'Component unmounted');
      wsRef.current = null;
    };
  }, [url]);

  const send = useCallback((data: string | ArrayBuffer | Blob) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(data);
    } else {
      console.warn('[WS] Cannot send: connection not open');
    }
  }, []);

  return { status, lastMessage, send };
}

3. Reconnect logic with exponential backoff

A WebSocket that does not automatically re-establish itself after a connection drop is unusable in production applications. Network hiccups, server restarts and timeouts are normal, the app has to handle them. The naive solution is an immediate reconnect after onclose, which, during a prolonged server outage, produces thousands of connection attempts per minute and keeps hammering the server.

The correct solution is exponential backoff: the first reconnect happens after 500ms, the next after 1s, then 2s, 4s, up to a maximum of 30s. An optional jitter component (plus or minus 20 percent random variation) prevents the so called thundering herd effect, where many clients reconnect simultaneously and overwhelm the server. After a successful connection, the backoff counter resets. The reconnect should stop once the component unmounts or the user has explicitly disconnected.

4. Mercure: Server-Sent Events with a hub

Mercure is an open protocol and hub server that extends SSE with topic based pub/sub. Instead of a single event stream URL for everything, publishers can publish targeted topics that subscribers listen to. That enables granular subscriptions: a user subscribes only to notifications for their own orders, not all orders in the system. The Mercure hub handles the routing, the application publishes an HTTP POST request to the hub, and the hub forwards it to every matching subscriber.

Mercure is integrated into Caddy and can run as a standalone hub server or as a Caddy module. Symfony has a native Mercure integration, but the hub works with any language and any framework that can send HTTP requests. The subscriber connection is a normal EventSource request with the topic as a query parameter. For private topics a JWT token is used that contains the allowed topics in its payload.


// useMercure.ts: Subscribe to Mercure topics via EventSource
import { useEffect, useRef, useState } from 'react';

interface MercureOptions {
  hubUrl: string;
  topics: string[];
  token?: string; // JWT for private topics
}

interface UseMercureReturn<T> {
  data: T | null;
  error: Event | null;
  connected: boolean;
}

export function useMercure<T = unknown>({
  hubUrl,
  topics,
  token,
}: MercureOptions): UseMercureReturn<T> {
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Event | null>(null);
  const [connected, setConnected] = useState(false);
  const esRef = useRef<EventSource | null>(null);

  useEffect(() => {
    // Build URL with topic query params
    const url = new URL(hubUrl);
    topics.forEach((t) => url.searchParams.append('topic', t));

    // Attach JWT as cookie or URL param (hub-dependent)
    const init: EventSourceInit = {};
    if (token) {
      // Some hubs accept token as URL param, others via cookie
      url.searchParams.set('authorization', token);
    }

    const es = new EventSource(url.toString(), init);
    esRef.current = es;

    es.onopen = () => setConnected(true);
    es.onerror = (e) => { setError(e); setConnected(false); };
    es.onmessage = (e) => {
      try {
        setData(JSON.parse(e.data) as T);
      } catch {
        setData(e.data as unknown as T);
      }
    };

    return () => { es.close(); esRef.current = null; setConnected(false); };
  }, [hubUrl, topics.join(','), token]);

  return { data, error, connected };
}

5. useMercure: topics and subscriptions in React

Using useMercure in practice follows the same pattern as any custom hook: the hook is called at the top level of the component and returns reactive data. What is special about Mercure is the topic concept: a topic is a URI that describes the subject of a message. For order updates the topic might be https://mironsoft.de/orders/42, exactly the order belonging to the logged in user.

When topics are dynamic (depending on props or state), they must be passed correctly as a dependency of the hook. A common pitfall is passing an array literal directly as the topics prop, which creates a new reference on every render and retriggers the effect. The solution is useMemo for the topics array, or stable string representations. For TypeScript projects it is worth building a generic hook variant that takes the message data type as a type parameter.

6. Authentication: JWT and Mercure authorization

Private Mercure topics require JWT authentication. The JWT contains a mercure claim with the allowed topics. The Mercure hub checks the token and allows or denies the subscription. The token is usually issued by the backend at login or on request, and has a short lifetime. A critical point: the token must be renewed before it expires, since an existing EventSource connection does not automatically renew its token.

For WebSocket authentication there are two common approaches: passing the token as a query parameter during connection setup (less secure, since it appears in logs) or sending the token in the first message after the handshake. The latter requires a server side protocol that treats the first message as an authentication frame. For Mercure with HTTP cookie based auth, the browser sends the cookie automatically, which is the most secure and simplest option.

7. State management for realtime data streams

Realtime data streams place special demands on state management. A single WebSocket event can affect many components, an order status update affects the order list, the order detail page and possibly a notification badge. The naive pattern of keeping the WebSocket state separately in every component leads to inconsistencies and duplicate connections. The solution is a central store concept.

With Zustand or Jotai you can build a global realtime store that is fed by a single useWebSocket hook or useMercure hook. Components subscribe only to the parts of the store that are relevant to them and only re-render when those parts change. For high frequency updates (for example a price ticker), throttling has to be applied: React should not process 60 state updates per second when the screen only renders 60 frames per second. useTransition or manual batching with flushSync help control the update frequency.


// realtimeStore.ts: Zustand store fed by WebSocket events
import { create } from 'zustand';

interface OrderUpdate {
  id: string;
  status: 'pending' | 'processing' | 'shipped' | 'delivered';
  updatedAt: string;
}

interface RealtimeState {
  orders: Record<string, OrderUpdate>;
  notifications: string[];
  updateOrder: (update: OrderUpdate) => void;
  addNotification: (message: string) => void;
}

export const useRealtimeStore = create<RealtimeState>((set) => ({
  orders: {},
  notifications: [],
  updateOrder: (update) =>
    set((state) => ({
      orders: { ...state.orders, [update.id]: update },
    })),
  addNotification: (message) =>
    set((state) => ({
      notifications: [...state.notifications.slice(-49), message],
    })),
}));

// Component: subscribe to Mercure and feed the store
function RealtimeProvider({ children }: { children: React.ReactNode }) {
  const updateOrder = useRealtimeStore((s) => s.updateOrder);
  const { data } = useMercure<OrderUpdate>({
    hubUrl: 'https://mercure.mironsoft.de/.well-known/mercure',
    topics: ['https://mironsoft.de/orders'],
  });

  useEffect(() => {
    if (data) updateOrder(data);
  }, [data, updateOrder]);

  return <>{children}</>;
}

8. Performance: optimizing connections

Every WebSocket or SSE connection consumes resources on both server and client. A common performance trap in React apps: several components each open their own connection to the same endpoint. The result is ten parallel WebSocket connections for ten open tabs, ten times the server overhead for the same data stream. The solution is the singleton pattern for connections: a single connection manager at the app level manages all connections and exposes them via context or a store.

For EventSource based SSE connections there is a further optimization: the browser's EventSource API automatically caches connections when several requests are sent to the same URL, but only within the same browsing context. For WebSocket multiplexing, libraries such as socket.io offer namespace concepts that route multiple logical channels over a single physical connection. With Mercure, a single EventSource with multiple topics as query parameters is enough.

9. WebSocket vs. Mercure head to head

The choice between WebSocket and Mercure is not a question of performance, but of architecture. Both technologies have different strengths, and the right choice depends on the specific use case.

Criterion WebSocket Mercure (SSE) Recommendation
Direction of communication Bidirectional Unidirectional (server to client) WS for chat/gaming, Mercure for notifications
Proxy compatibility Problematic Unproblematic (HTTP) Prefer Mercure inside enterprise networks
Authentication Implement manually JWT native in the hub Mercure for private topics
Topic routing Application layer Hub handles routing Mercure for multi-topic scenarios
Server infrastructure WebSocket capable server required Standard HTTP server is enough Mercure is simpler to deploy

In modern projects, WebSocket and Mercure are often combined: Mercure for push notifications and status updates, WebSocket for interactive realtime features such as collaborative editing. The backend publishes events to the Mercure hub via HTTP POST, which works with any backend framework and requires no persistent connection management on the server side.

Mironsoft

React Realtime · WebSocket · Mercure · Realtime Architecture

Need realtime features for your React app?

We design and implement realtime architectures with WebSocket and Mercure, from custom hooks through reconnect logic to scalable hub infrastructure.

Architecture design

WebSocket vs. Mercure, the right choice for your use case

Hook development

Robust custom hooks with reconnect, auth and state management

Hub setup

Set up a Mercure hub with Caddy and integrate it into your existing backend infrastructure

10. Summary

WebSocket and Mercure solve the same underlying problem in different ways: realtime push from server to client without polling. WebSocket is the right choice for bidirectional, interactive features. Mercure is the better choice for unidirectional notifications, status updates and events, because it builds on standard HTTP, handles topic routing and supports JWT authentication natively. In React, both are wrapped in custom hooks that internally manage connection status, reconnect logic and state management, and expose the component a clean API.

State management for realtime data belongs in a central store, not in local component state. Singleton connections prevent multiple parallel connections. Throttling and batching protect against excessive re-renders during high frequency updates. With these building blocks you can build realtime features that stay reliable even under load.

React Realtime with WebSocket and Mercure, the essentials at a glance

Hook design

useWebSocket and useMercure encapsulate connection, status and reconnect internally. Components get only clean data and a send function.

Reconnect

Exponential backoff with jitter prevents thundering herd. Reset backoff after a successful connection. Stop reconnecting on an explicit disconnect.

State management

Central store (Zustand/Jotai) instead of local state per component. A single hook feeds the store, many components read from it.

WebSocket vs. Mercure

WebSocket for bidirectional features, Mercure for push notifications. Mercure is more proxy friendly and has native JWT auth. Combining both is a valid pattern.

11. FAQ: React Realtime with WebSocket and Mercure

1WebSocket vs. Server-Sent Events?
WebSocket is bidirectional, SSE is unidirectional (server to client). SSE is HTTP based and more proxy friendly. WebSocket for chat/gaming, SSE for notifications.
2What is Mercure?
SSE plus topic routing plus JWT auth. The hub takes an HTTP POST from the publisher and delivers via EventSource to subscribers. A Caddy module or a standalone server.
3Preventing duplicate connections?
A singleton connection manager via context or a global store. Components read from the store, they do not open their own connections.
4Exponential backoff?
Increase the wait time exponentially: 500ms to 1s to 2s to a max of 30s. Jitter of plus or minus 20 percent prevents thundering herd. Reset the counter after success.
5Authenticating private Mercure topics?
Issue a JWT with a mercure claim from the backend. Pass it to the hub via cookie (secure) or URL parameter. The hub checks the allowed topics.
6High frequency updates in React?
Throttling and batching. useTransition prioritizes. A Zustand store with selective subscriptions prevents unnecessary re-renders.
7StrictMode issue with WebSocket?
StrictMode runs effects twice. Cleanup in the useEffect return closes the connection correctly. wsRef prevents state updates on stale connections.
8Testing WebSocket hooks?
Mock with MSW v2's native WebSocket support. For integration tests, start a local WS server with the ws package.
9Mercure without Symfony?
Yes. Any backend can send an HTTP POST to the hub. The Mercure hub runs as a Caddy module or a standalone Go server.
10Combining WebSocket and Mercure?
A valid pattern: WS for interactive features, Mercure for push notifications. Both can feed the same Zustand store.