Skip to main content

WebSockets vs Server-Sent Events vs Long Polling: Which to Use and When

June 2, 2026

</>

Adding real-time functionality to a web application — live notifications, chat, live dashboards, collaborative editing — requires choosing between three fundamentally different communication patterns: WebSockets, Server-Sent Events (SSE), and Long Polling. Each solves the same problem differently and excels in different scenarios.

Choosing the wrong approach creates scalability problems, unnecessary complexity, or browser compatibility issues. This post explains each pattern, provides implementation examples, and gives you a concrete decision framework.


The Core Difference

COMMUNICATION PATTERNS:

Long Polling:
Client ──► Server (request)
Client ◄── Server (waits and responds when data ready)
Client ──► Server (immediately re-polls)

Server-Sent Events:
Client ──► Server (one-time connection request)
Client ◄── Server (continuous stream of events, server-to-client only)
Client       Server (no client-to-server communication after connection)

WebSockets:
Client ◄──► Server (full-duplex, bidirectional)
Client ◄──► Server (both sides send messages freely)

Long Polling

The client sends an HTTP request. The server holds the connection open until it has data to send, then responds. The client immediately sends another request, creating a polling loop.

When to use it:

  • You need maximum compatibility (works everywhere HTTP works).
  • Real-time updates are infrequent (less than once every few seconds).
  • You can't use WebSockets due to infrastructure constraints (some proxies block upgrades).

Next.js Implementation

// app/api/notifications/poll/route.ts
import { NextResponse } from 'next/server';

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const lastEventId = searchParams.get('lastEventId') ?? '0';
  const userId = await getUserIdFromRequest(request);

  // Hold the connection for up to 25 seconds, checking for new events
  const startTime = Date.now();
  const timeout = 25000; // 25 second hold

  while (Date.now() - startTime < timeout) {
    const newNotifications = await db.query(
      'SELECT * FROM notifications WHERE user_id = $1 AND id > $2 ORDER BY id ASC LIMIT 10',
      [userId, lastEventId]
    );

    if (newNotifications.rows.length > 0) {
      return NextResponse.json({
        notifications: newNotifications.rows,
        lastEventId: newNotifications.rows.at(-1)!.id,
      });
    }

    // Wait 1 second before checking again
    await new Promise(resolve => setTimeout(resolve, 1000));
  }

  // Timeout: return empty response, client will re-poll
  return NextResponse.json({ notifications: [], lastEventId });
}
// Client-side long polling loop
async function startLongPolling(userId: string) {
  let lastEventId = '0';

  while (true) {
    try {
      const response = await fetch(
        `/api/notifications/poll?lastEventId=${lastEventId}`
      );
      const data = await response.json();

      if (data.notifications.length > 0) {
        renderNotifications(data.notifications);
        lastEventId = data.lastEventId;
      }
    } catch (error) {
      // Connection error — wait before retrying
      await new Promise(resolve => setTimeout(resolve, 5000));
    }
  }
}

Drawbacks: Each poll holds a server connection. At scale (10,000 concurrent users), this can exhaust connection pools.


Server-Sent Events (SSE)

SSE creates a persistent HTTP connection from client to server. The server pushes events whenever it has data. The connection is one-directional — the client cannot send messages back.

When to use it:

  • Server needs to push updates to the client only (no client-to-server after connection).
  • You want a simple implementation without WebSocket complexity.
  • Use cases: live activity feeds, notification streams, progress updates, log streaming.

Next.js Implementation

// app/api/notifications/stream/route.ts
export async function GET(request: Request) {
  const userId = await getUserIdFromRequest(request);

  const encoder = new TextEncoder();
  let lastEventId = '0';

  const stream = new ReadableStream({
    async start(controller) {
      // Send an initial ping to establish the connection
      controller.enqueue(encoder.encode(': ping\n\n'));

      // Check for new events every 2 seconds
      const interval = setInterval(async () => {
        try {
          const newNotifications = await db.query(
            'SELECT * FROM notifications WHERE user_id = $1 AND id > $2 ORDER BY id ASC',
            [userId, lastEventId]
          );

          for (const notification of newNotifications.rows) {
            // SSE format: "data: {json}\n\n"
            const event = `id: ${notification.id}\ndata: ${JSON.stringify(notification)}\n\n`;
            controller.enqueue(encoder.encode(event));
            lastEventId = notification.id;
          }
        } catch (error) {
          clearInterval(interval);
          controller.close();
        }
      }, 2000);

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

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  });
}
// Client-side: EventSource API — built into every browser
function connectToNotificationStream() {
  const eventSource = new EventSource('/api/notifications/stream');

  eventSource.onmessage = (event) => {
    const notification = JSON.parse(event.data);
    renderNotification(notification);
  };

  eventSource.onerror = () => {
    // Browser automatically reconnects on error
    console.log('SSE connection lost, reconnecting...');
  };

  // Cleanup
  return () => eventSource.close();
}

Key advantage: Browsers automatically reconnect SSE streams and support the Last-Event-ID header for resuming after disconnection.


WebSockets

WebSockets provide a full-duplex, persistent connection. Both server and client can send messages at any time after the handshake.

When to use it:

  • You need bidirectional communication (client sends and receives messages).
  • Use cases: real-time chat, collaborative editing, live multiplayer games, trading dashboards.
  • High-frequency updates (millisecond latency matters).

Next.js + Socket.io Implementation

pnpm add socket.io socket.io-client
// server.ts — Custom Next.js server with Socket.io
import { createServer } from 'http';
import next from 'next';
import { Server } from 'socket.io';

const app = next({ dev: process.env.NODE_ENV !== 'production' });
const handler = app.getRequestHandler();

app.prepare().then(() => {
  const httpServer = createServer(handler);
  const io = new Server(httpServer, {
    cors: { origin: process.env.NEXT_PUBLIC_URL },
  });

  io.on('connection', (socket) => {
    console.log(`Client connected: ${socket.id}`);

    // Join a chat room
    socket.on('join-room', (roomId: string) => {
      socket.join(roomId);
      io.to(roomId).emit('user-joined', { userId: socket.id });
    });

    // Broadcast message to room
    socket.on('send-message', (data: { roomId: string; message: string; userId: string }) => {
      io.to(data.roomId).emit('new-message', {
        id: crypto.randomUUID(),
        message: data.message,
        userId: data.userId,
        timestamp: new Date().toISOString(),
      });
    });

    socket.on('disconnect', () => {
      console.log(`Client disconnected: ${socket.id}`);
    });
  });

  httpServer.listen(3000);
});
// Client-side
import { io } from 'socket.io-client';

const socket = io(process.env.NEXT_PUBLIC_URL!);

socket.on('connect', () => {
  socket.emit('join-room', 'room-123');
});

socket.on('new-message', (message) => {
  renderMessage(message);
});

function sendMessage(text: string) {
  socket.emit('send-message', {
    roomId: 'room-123',
    message: text,
    userId: currentUserId,
  });
}

Comparison Table

FeatureLong PollingSSEWebSockets
DirectionClient-initiated, server respondsServer → Client onlyBidirectional
ProtocolHTTPHTTPWebSocket (ws://)
Browser supportUniversalUniversalUniversal
Auto-reconnectManual✅ Built-inManual
LatencyHigh (1s+ polling interval)Low (immediate push)Very low (ms)
ComplexityLowLowHigh
Serverless-friendly✅ Yes✅ Yes⚠️ Difficult
Proxy-friendly✅ Yes✅ Yes⚠️ Sometimes blocked
Scale⚠️ Connection overhead✅ Good✅ Good

Decision Framework

Does the client need to SEND real-time messages to the server?
├── YES  WebSockets
└── NO
    
    Does the server need to push updates immediately (< 1s latency)?
    ├── YES  Server-Sent Events
    └── NO (updates every few seconds are fine)
        
        Are you deploying to serverless/edge functions?
        ├── YES  Long Polling (or SSE)
        └── NO  Server-Sent Events (simpler than Long Polling)

Conclusion

There is no universally superior real-time pattern. Long polling is the safest compatibility choice when updates are infrequent. Server-Sent Events are the right default for server-to-client push with minimal complexity. WebSockets are necessary when bidirectional, low-latency communication is required. Match the pattern to your use case, not to the technology you find most interesting, and you'll save significant debugging time down the road.

Recommended Posts