Building real-time features in Next.js without a dedicated WebSocket server

By Yuki Tanaka · 20 July 2026156 views
Building real-time features in Next.js without a dedicated WebSocket server

A developer building a collaborative SaaS tool is told the feature requirements include real-time notifications and live status updates. She immediately opens a ticket to provision a WebSocket server — a separate service, persistent connections, custom deployment, and a state management layer to sync clients. Two weeks later the feature is delayed. The infrastructure work took longer than expected.

The same feature could have shipped in two days using Server-Sent Events for notifications and Firestore listeners for live status. The WebSocket server was not wrong — it was more than the job required. Choosing a technology because it sounds right for the category ("real-time sounds like WebSockets") rather than because it fits the actual requirements is a common reason projects run late.

What real-time actually means in most applications

Most "real-time" requirements fall into a few categories, and the right technology differs by category:

  • Server pushes updates to clients (notifications, status changes, live counts) — one direction, server to client
  • Client polls for new data (feed updates, background sync) — periodic, not truly real-time
  • Live presence and collaborative editing (multiple users editing the same document simultaneously) — bidirectional, complex

WebSockets are the right tool for the third category. Server-Sent Events handle the first. Polling handles the second. The mistake is choosing WebSockets for the first two categories, which is engineering overhead those requirements do not justify.

Understanding the distinction matters because each approach has a different operational profile. A WebSocket server requires maintaining persistent connections, handling reconnection state, managing connection counts for scaling, and building client-side sync logic for state that changes while a connection is dropped. SSE and Firestore listeners handle reconnection automatically and have no persistent server state to manage.

Server-Sent Events in Next.js App Router

SSE uses a long-lived HTTP connection over which the server pushes text events. The browser reconnects automatically if the connection drops. No WebSocket protocol — just HTTP with a streaming response body.

The App Router's Route Handlers can return a ReadableStream, which makes SSE straightforward to implement:

// app/api/notifications/stream/route.ts
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const userId = searchParams.get('userId');

  if (!userId) {
    return Response.json({ error: 'userId required' }, { status: 400 });
  }

  const stream = new ReadableStream({
    start(controller) {
      const encoder = new TextEncoder();

      // Helper to send an SSE event
      const send = (event: string, data: unknown) => {
        const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
        controller.enqueue(encoder.encode(message));
      };

      // Initial heartbeat to confirm connection
      send('connected', { timestamp: Date.now() });

      // Subscribe to notifications for this user
      const unsubscribe = notificationService.subscribe(userId, (notification) => {
        send('notification', notification);
      });

      // Heartbeat every 30 seconds to keep the connection alive through proxies
      // Many load balancers and proxies close idle connections after 60s
      const heartbeat = setInterval(() => {
        send('heartbeat', { timestamp: Date.now() });
      }, 30000);

      // Clean up when the client disconnects
      request.signal.addEventListener('abort', () => {
        unsubscribe();
        clearInterval(heartbeat);
        controller.close();
      });
    }
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
      // Required for certain reverse proxies to disable buffering
      'X-Accel-Buffering': 'no',
    },
  });
}

The notificationService.subscribe call is application-specific — it could subscribe to a Redis pub/sub channel, a database change stream, or an in-memory event emitter. The SSE route is the HTTP boundary; the notification service is the delivery mechanism.

Client-side connection in React:

// hooks/useNotifications.ts
import { useEffect, useState } from 'react';

interface Notification {
  id: string;
  message: string;
  type: 'info' | 'warning' | 'error';
  timestamp: number;
}

export function useNotifications(userId: string) {
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [connected, setConnected] = useState(false);

  useEffect(() => {
    const eventSource = new EventSource(`/api/notifications/stream?userId=${userId}`);

    eventSource.addEventListener('connected', () => {
      setConnected(true);
    });

    eventSource.addEventListener('notification', (event) => {
      const notification: Notification = JSON.parse(event.data);
      setNotifications(prev => [notification, ...prev].slice(0, 50));
    });

    eventSource.addEventListener('error', () => {
      setConnected(false);
      // EventSource reconnects automatically — no manual reconnection logic needed
      // The browser will retry with an exponential backoff
    });

    return () => {
      eventSource.close();
    };
  }, [userId]);

  return { notifications, connected };
}

The EventSource API handles reconnection automatically. If the connection drops, the browser reconnects and includes the Last-Event-ID header if the server set event IDs. The server can use this header to replay missed events:

// Route handler with event ID support for replay
export async function GET(request: Request) {
  const lastEventId = request.headers.get('Last-Event-ID');
  const userId = new URL(request.url).searchParams.get('userId')!;

  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();
      let eventId = 0;

      const send = (event: string, data: unknown) => {
        eventId++;
        const message = `id: ${eventId}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
        controller.enqueue(encoder.encode(message));
      };

      // Replay missed events if client reconnected
      if (lastEventId) {
        const missed = await getMissedNotifications(userId, parseInt(lastEventId));
        for (const n of missed) {
          send('notification', n);
        }
      }

      const unsubscribe = notificationService.subscribe(userId, (notification) => {
        send('notification', notification);
      });

      request.signal.addEventListener('abort', () => {
        unsubscribe();
        controller.close();
      });
    }
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
    },
  });
}

SSE limitations worth knowing: one direction only (server to client), text-only (not binary), and HTTP/1.1 browsers limit to 6 concurrent connections per origin (HTTP/2 removes this limit). For notification systems, status dashboards, and live feeds, these are not practical limitations.

Firestore real-time listeners in Next.js

Firestore's real-time listeners run in the browser and receive updates when documents change, without any server infrastructure. For status updates and live data that is already stored in Firestore, this is often the simplest path to real-time behavior.

// hooks/useOrderStatus.ts
import { doc, onSnapshot } from 'firebase/firestore';
import { useEffect, useState } from 'react';
import { db } from '@/lib/firebase';

type OrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled';

interface OrderStatusState {
  status: OrderStatus | null;
  updatedAt: Date | null;
  loading: boolean;
  error: Error | null;
}

export function useOrderStatus(orderId: string): OrderStatusState {
  const [state, setState] = useState<OrderStatusState>({
    status: null,
    updatedAt: null,
    loading: true,
    error: null,
  });

  useEffect(() => {
    const orderRef = doc(db, 'orders', orderId);

    const unsubscribe = onSnapshot(
      orderRef,
      (snapshot) => {
        if (snapshot.exists()) {
          setState({
            status: snapshot.data().status,
            updatedAt: snapshot.data().updatedAt?.toDate() ?? null,
            loading: false,
            error: null,
          });
        } else {
          setState(prev => ({ ...prev, loading: false }));
        }
      },
      (error) => {
        setState(prev => ({ ...prev, loading: false, error }));
      }
    );

    return unsubscribe;  // Cleanup on unmount
  }, [orderId]);

  return state;
}

A server-side process — a Cloud Function, a background worker, or an API route — updates the Firestore document when the order status changes. The client receives the update within 100-500ms without any polling mechanism. Firestore handles the connection, reconnection, and offline persistence automatically.

// Component using real-time status
const STATUS_LABELS: Record<string, string> = {
  pending: 'Order received',
  processing: 'Preparing your order',
  shipped: 'On the way',
  delivered: 'Delivered',
  cancelled: 'Cancelled',
};

function OrderStatusBadge({ orderId }: { orderId: string }) {
  const { status, updatedAt, loading, error } = useOrderStatus(orderId);

  if (loading) return <span className="badge badge-loading">Checking status...</span>;
  if (error) return <span className="badge badge-error">Status unavailable</span>;
  if (!status) return null;

  return (
    <div>
      <span className={`badge badge-${status}`}>
        {STATUS_LABELS[status] ?? status}
      </span>
      {updatedAt && (
        <time className="status-time">
          Updated {updatedAt.toLocaleTimeString()}
        </time>
      )}
    </div>
  );
}

Firestore listeners work at the collection level too, which is useful for live lists:

// hooks/useActiveUsers.ts — watch who's currently online
import { collection, query, where, onSnapshot } from 'firebase/firestore';

export function useActiveUsers(roomId: string) {
  const [activeUsers, setActiveUsers] = useState<User[]>([]);

  useEffect(() => {
    const q = query(
      collection(db, 'presence'),
      where('roomId', '==', roomId),
      where('lastSeen', '>', new Date(Date.now() - 5 * 60 * 1000))  // Active in last 5 minutes
    );

    return onSnapshot(q, (snapshot) => {
      setActiveUsers(snapshot.docs.map(doc => ({
        id: doc.id,
        ...doc.data() as Omit<User, 'id'>
      })));
    });
  }, [roomId]);

  return activeUsers;
}

The operational cost of Firestore listeners is Firestore read pricing — each document update delivered to an active listener counts as a read. For high-frequency updates (many changes per second across many documents), SSE with a controlled server-side emission rate may be more cost-effective.

Efficient polling as a fallback

For data that updates infrequently and does not need immediate push delivery, polling with exponential backoff is reliable and simple. It requires no persistent server connections and works behind any infrastructure.

// hooks/usePoll.ts
import { useEffect, useRef, useState, useCallback } from 'react';

interface PollOptions {
  intervalMs: number;
  maxIntervalMs?: number;
  backoffMultiplier?: number;
}

function usePoll<T>(fetcher: () => Promise<T>, options: PollOptions) {
  const { intervalMs, maxIntervalMs = intervalMs * 10, backoffMultiplier = 1 } = options;
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);
  const currentInterval = useRef(intervalMs);
  const timeoutRef = useRef<NodeJS.Timeout>();

  const poll = useCallback(async () => {
    try {
      const result = await fetcher();
      setData(result);
      setError(null);
      currentInterval.current = intervalMs;  // Reset on success
    } catch (err) {
      setError(err instanceof Error ? err : new Error(String(err)));
      // Back off on errors
      currentInterval.current = Math.min(
        currentInterval.current * backoffMultiplier,
        maxIntervalMs
      );
    }
    timeoutRef.current = setTimeout(poll, currentInterval.current);
  }, [fetcher, intervalMs, maxIntervalMs, backoffMultiplier]);

  useEffect(() => {
    poll();
    return () => {
      if (timeoutRef.current) clearTimeout(timeoutRef.current);
    };
  }, [poll]);

  return { data, error };
}

// Usage — check for new messages every 10 seconds, back off on error
const { data: messages } = usePoll(
  () => fetchMessages(conversationId),
  { intervalMs: 10000, maxIntervalMs: 60000, backoffMultiplier: 2 }
);

Polling also works well alongside Next.js Route Handlers with conditional responses. If the data has not changed since the client last fetched it, the server can return 304 Not Modified:

// app/api/feed/route.ts — supports conditional GET
export async function GET(request: Request) {
  const lastModified = await getFeedLastModified();
  const ifModifiedSince = request.headers.get('If-Modified-Since');

  if (ifModifiedSince && new Date(ifModifiedSince) >= lastModified) {
    return new Response(null, { status: 304 });
  }

  const feed = await getFeedItems();

  return Response.json(feed, {
    headers: {
      'Last-Modified': lastModified.toUTCString(),
      'Cache-Control': 'no-cache',
    },
  });
}

The client includes If-Modified-Since in subsequent requests. If the feed has not changed, the server returns a 304 with no body — the response is nearly free.

Handling connection state and reconnection

Real-time features should surface connection state to the user. A notification badge that silently stops receiving updates is worse than one that shows "offline — reconnecting":

// hooks/useSSEConnection.ts
import { useEffect, useState, useCallback } from 'react';

type ConnectionState = 'connecting' | 'connected' | 'disconnected';

export function useSSEConnection(url: string) {
  const [state, setState] = useState<ConnectionState>('connecting');
  const [retryCount, setRetryCount] = useState(0);

  useEffect(() => {
    let eventSource: EventSource;
    let reconnectTimeout: NodeJS.Timeout;

    const connect = () => {
      setState('connecting');
      eventSource = new EventSource(url);

      eventSource.addEventListener('connected', () => {
        setState('connected');
        setRetryCount(0);
      });

      eventSource.addEventListener('error', () => {
        setState('disconnected');
        eventSource.close();

        // Manual reconnect with exponential backoff
        const delay = Math.min(1000 * Math.pow(2, retryCount), 30000);
        setRetryCount(prev => prev + 1);
        reconnectTimeout = setTimeout(connect, delay);
      });
    };

    connect();

    return () => {
      eventSource?.close();
      clearTimeout(reconnectTimeout);
    };
  }, [url, retryCount]);

  return state;
}

When to use a WebSocket server

WebSockets are the right tool when:

  • The feature requires bidirectional communication where both client and server send messages (chat, multiplayer games, collaborative editing)
  • Latency must be consistently under 50ms (game state sync, live audio/video signaling)
  • Binary data transfer is needed (audio frames, compressed game state)
  • The feature involves operational transforms or CRDTs for conflict-free collaborative editing

For a notification system, a live status badge, a comment feed, or a typing indicator — SSE or Firestore listeners handle these without the infrastructure cost of a WebSocket server.

The practical test: if the only messages going client-to-server are REST API calls (button clicks, form submissions), the connection is effectively one-directional and SSE is the better fit. If the client is sending a continuous stream of data back to the server (cursor position in a shared canvas, keystrokes in a collaborative editor), the connection is genuinely bidirectional and WebSockets are justified.

Common mistakes when implementing real-time features

Mistake 1: Not handling the case where the connection drops. SSE and WebSockets both need reconnection handling. The EventSource API reconnects automatically, but that reconnection may deliver events out of order or miss events. Use event IDs and replay logic for notifications where missing an event matters.

Mistake 2: Creating a new EventSource on every render. The EventSource should be created once per userId (or connection key) and cleaned up on unmount. Wrapping it in useEffect with the correct dependency array prevents connection leaks.

Mistake 3: Sending too many Firestore updates. If a background process updates a document every 100ms and there are 1,000 active listeners, each update costs 1,000 Firestore reads. Rate-limit writes to a frequency that matches user expectations — most status updates do not need sub-second precision.

Mistake 4: Not testing behind a proxy or load balancer. Many reverse proxies buffer streaming responses. The X-Accel-Buffering: no header disables this for Nginx. In production, verify that SSE events arrive with expected latency, not in batches after the proxy flushes its buffer.

Mistake 5: Building real-time features before validating that users need them. Polling every 30 seconds with a "Refresh" button often satisfies users who were described as needing "real-time updates." The complexity cost of a persistent connection is high — validate the actual latency requirement before choosing the more complex approach.

The decision to introduce a WebSocket server should be made because the feature requires it, not because "real-time" sounds like WebSockets.

Choosing the right approach for your feature

A practical decision guide:

Notification delivery (order status, payment confirmation, system alerts): Server-Sent Events. The server pushes events; the client only receives. SSE with automatic reconnection handles transient connection drops without custom logic.

Live document or record status (order processing state, build pipeline status, moderation queue): Firestore real-time listeners. If the data is already stored in Firestore, the listener is a single onSnapshot call. No server infrastructure required.

Live feed or activity stream (social activity, comment threads, chat history): SSE for push delivery, or polling with a short interval if near-real-time is acceptable. A 5-second polling interval with conditional GET headers is often indistinguishable from true real-time for chat features with low message volume.

Collaborative editing, live cursors, shared canvas: WebSockets. These features require the server to broadcast one client's input to all other clients in near-real-time, which is genuinely bidirectional and stateful in ways that SSE and polling cannot match efficiently.

The infrastructure cost of a WebSocket server — connection management, scaling considerations, deployment complexity — is justified only when the feature's requirements genuinely cannot be met by the simpler alternatives. Start with SSE or Firestore listeners and add complexity when you hit a real constraint, not before.

Comments

No comments yet. Be the first!

Sign in to leave a comment.