The essentials

Quick reference

One focused task per row. Jump to the related section for complete, working examples.

UseSyntaxExamples
Open a WebSocketconst socket = new WebSocket('wss://realtime.example.com/v1')View examples
Open an event streamconst events = new EventSource('/api/events')View examples
Include cross-origin credentialsconst events = new EventSource('https://events.example/stream', { withCredentials: true })View examples
Offer subprotocolsconst socket = new WebSocket(url, ['realtime.v2', 'realtime.v1'])View examples
Wait for an open connectionsocket.addEventListener('open', handleOpen, { once: true })View examples
Check the ready stateif (socket.readyState === WebSocket.OPEN) socket.send(payload)View examples
Start a normal closesocket.close(1000, 'view unmounted')View examples
Send a JSON messagesocket.send(JSON.stringify({ type: 'subscribe', topic: 'orders' }))View examples
Receive a WebSocket messagesocket.addEventListener('message', event => routeMessage(event.data))View examples
Receive binary as ArrayBuffersocket.binaryType = 'arraybuffer'View examples
Send binary datasocket.send(new Uint8Array([1, 2, 3]))View examples
Inspect queued outgoing bytesconst queuedBytes = socket.bufferedAmountView examples
Apply an outgoing high-water markif (socket.bufferedAmount < 262144) socket.send(nextMessage)View examples
Handle default SSE messagesevents.addEventListener('message', event => render(JSON.parse(event.data)))View examples
Handle a named SSE eventevents.addEventListener('order.updated', handleOrderUpdate)View examples
Read the last event IDconst cursor = event.lastEventIdView examples
Resume from an SSE cursorLast-Event-ID: 1042View examples
Set the SSE retry delayretry: 5000View examples
Send an SSE comment heartbeat: heartbeatView examples
Inspect EventSource stateif (events.readyState === EventSource.CONNECTING) showReconnecting()View examples
Stop EventSource reconnectsevents.close()View examples
Restrict realtime endpointsContent-Security-Policy: connect-src 'self' wss://realtime.example.com https://events.example.comView examples

WebSocket and EventSource solve different realtime problems. WebSocket is a bidirectional, message-oriented channel for text or binary data. EventSource receives UTF-8 text events over HTTP and supplies reconnection and event-ID resumption, while upstream commands use a separate request. Neither API supplies application delivery guarantees, durable replay, or an unlimited queue. Reliable deployments choose the simpler transport that fits the direction of traffic, define an application protocol, bound work and buffers, authenticate the handshake, survive intermediaries, and close connections when their owner is finished.

Step by step

Detailed examples

01

Choose from direction, payload, and recovery needs

Use EventSource when the browser mainly receives notifications, progress, logs, or projections. It uses an HTTP GET response with text/event-stream, always decodes UTF-8, understands named events, and reconnects automatically. Send browser-to-server commands separately with fetch. Use WebSocket when both peers send frequently, low-overhead application messages matter, or binary payloads are required. WebSocket preserves message order on one live connection, but neither transport makes a processed message durable or exactly-once across disconnects. Put stable IDs, versions, acknowledgements, deduplication keys, and replay rules in the application protocol when the product needs them. Plain polling or a streamed fetch can still be preferable for finite results, cache-friendly data, or infrastructure that cannot sustain long-lived connections.

Keep SSE downstream and commands explicit
const events = new EventSource('/api/orders/events');

events.addEventListener('order.updated', event => {
  const order = JSON.parse(event.data);
  updateOrder(order);
});

async function cancelOrder(orderId) {
  const response = await fetch(`/api/orders/${encodeURIComponent(orderId)}/cancel`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: '{}'
  });
  if (!response.ok) throw new Error(`Cancel failed: ${response.status}`);
}
Back to quick reference ↑
02

Treat the WebSocket handshake and close as states

Construction begins the handshake immediately. send while CONNECTING throws; sends after closing begins do not restore the connection, so gate production on OPEN. The offered subprotocol list is an ordered set of application protocol names, not arbitrary headers or authentication tokens. Verify socket.protocol after open when a subprotocol is required. error intentionally exposes little diagnostic detail; close provides code, reason, and wasClean, although wasClean does not prove that application messages were processed. Browser script may pass only 1000 or an application code from 3000 through 4999 to close(); protocol codes such as 1002 are reserved for the WebSocket implementation. A close reason is limited to 123 UTF-8 bytes.

Validate the negotiated protocol and report closure
const socket = new WebSocket('wss://realtime.example.com/v2', ['realtime.v2']);

socket.addEventListener('open', () => {
  if (socket.protocol !== 'realtime.v2') {
    socket.close(4002, 'subprotocol required');
    return;
  }
  socket.send(JSON.stringify({ type: 'hello', clientVersion: 2 }));
});

socket.addEventListener('close', event => {
  console.info('WebSocket closed', {
    code: event.code,
    reason: event.reason,
    wasClean: event.wasClean
  });
});
Back to quick reference ↑
03

Define application messages independently of wire frames

The browser WebSocket API delivers complete messages through MessageEvent. A text message is valid UTF-8 and event.data is a string. A binary message is a Blob by default or an ArrayBuffer when binaryType is arraybuffer. Protocol fragmentation is invisible to page code: one message can occupy several frames, control frames can appear between fragments, and intermediaries may change frame boundaries. Do not encode application records around observed packet or frame sizes. Instead, define an envelope with a type, schema version, correlation ID, and bounded payload; validate every incoming value before dispatch. The browser API does not expose protocol Ping or Pong frames. If application liveness needs browser participation, define a small application-level heartbeat and keep it distinct from transport control frames.

Validate text envelopes and handle binary messages separately
socket.binaryType = 'arraybuffer';

socket.addEventListener('message', event => {
  if (event.data instanceof ArrayBuffer) {
    consumeBinaryPacket(new Uint8Array(event.data));
    return;
  }

  let message;
  try {
    message = JSON.parse(event.data);
  } catch {
    socket.close(1007, 'invalid JSON');
    return;
  }

  if (message?.version !== 2 || typeof message.type !== 'string') {
    socket.close(1008, 'unsupported envelope');
    return;
  }
  routeMessage(message);
});
Back to quick reference ↑
04

Bound producers because classic WebSocket has no backpressure

WebSocket.send queues data synchronously. bufferedAmount counts application bytes queued by send but excludes protocol framing and lower network buffers; it is a signal, not a promise that the peer processed anything. The classic API has no drain event and no browser-exposed incoming backpressure. Sample bufferedAmount on a timer or on producer ticks, pause or coalesce replaceable updates above a high-water mark, set a maximum message size in the application, and close chronically slow clients on the server. For incoming floods, validate size before expensive parsing where possible, keep handlers short, batch rendering, and make the server enforce per-connection rate and queue limits. EventSource likewise exposes no stream reader or flow-control knob, so servers must bound each subscriber's pending data rather than buffering without limit.

Coalesce replaceable WebSocket updates behind a high-water mark
const HIGH_WATER_BYTES = 256 * 1024;
let latestPresence = null;

function publishPresence(presence) {
  latestPresence = presence;
  flushPresence();
}

function flushPresence() {
  if (latestPresence === null || socket.readyState !== WebSocket.OPEN) return;
  if (socket.bufferedAmount >= HIGH_WATER_BYTES) return;

  const message = JSON.stringify({ type: 'presence', value: latestPresence });
  latestPresence = null;
  socket.send(message);
}

const flushTimer = setInterval(flushPresence, 100);
Back to quick reference ↑
05

Frame SSE records with fields and a terminating blank line

An event stream response has Content-Type: text/event-stream and consists of UTF-8 lines. data lines are joined with newline characters; a blank line dispatches the accumulated event. event selects a named DOM event, while its absence produces message. id updates the connection's last event ID, and a numeric retry value changes the reconnection delay. On reconnect the browser sends a Last-Event-ID request header when that ID is non-empty, so IDs should identify a resumable position and the server should replay strictly after it. EventSource does not invent storage: retain an appropriately bounded event log or return an explicit reset snapshot when a cursor is too old. Lines beginning with a colon are comments and can keep an otherwise idle path active without dispatching a message.

Emit a resumable named event and a comment heartbeat
Content-Type: text/event-stream
Cache-Control: no-cache

id: 1042
event: order.updated
data: {"id":"A17","status":"packed"}
retry: 5000

: heartbeat

Read the server cursor without managing reconnects manually
const events = new EventSource('/api/orders/events');

events.addEventListener('order.updated', event => {
  const order = JSON.parse(event.data);
  console.debug('Applied event', event.lastEventId);
  updateOrder(order);
});

events.addEventListener('error', () => {
  if (events.readyState === EventSource.CONNECTING) {
    showStatus('Reconnecting…');
  } else {
    showStatus('Event stream closed');
  }
});
Back to quick reference ↑
06

Reconnect WebSockets deliberately and keep intermediaries visible

EventSource reconnects according to its processing model until close is called or the connection fails fatally; a 204 response tells the browser to stop reconnecting. It can send Last-Event-ID automatically. WebSocket has no automatic reconnect or resume, so use capped exponential backoff with jitter, reset attempts only after a useful connection, and stop on intentional closure, authentication failure, or a non-retryable application decision. Every reconnect creates a new session: resubscribe and resume from acknowledged application cursors instead of assuming the old socket continued. Reverse proxies, load balancers, and NAT mappings can terminate idle connections or buffer SSE. Configure streaming paths to avoid response buffering, align idle timeouts end to end, and send modest heartbeats. SSE can use comment lines; a WebSocket server can use protocol Ping, while browser code needs an application message if it must measure peer responsiveness.

Reconnect a WebSocket with capped full jitter
let socket;
let reconnectTimer;
let attempt = 0;
let stopped = false;

function connect() {
  if (stopped) return;
  socket = new WebSocket('wss://realtime.example.com/v1');

  socket.addEventListener('open', () => {
    socket.send(JSON.stringify({ type: 'resume', after: lastAcknowledgedId }));
  });

  socket.addEventListener('message', event => {
    const message = parseValidatedMessage(event.data);
    if (message.type === 'resumed') attempt = 0;
    routeMessage(message);
  });

  socket.addEventListener('close', event => {
    if (stopped || event.code === 1000 || event.code === 1008) return;
    const ceiling = Math.min(30_000, 1_000 * 2 ** Math.min(attempt++, 5));
    reconnectTimer = setTimeout(connect, Math.random() * ceiling);
  });
}

connect();
Back to quick reference ↑
07

Authorize the connection, not merely its URL

Use wss and HTTPS in production so credentials and messages are protected in transit. Browser WebSocket handshakes can include ambient cookies, but the constructor cannot set arbitrary Authorization headers; EventSource similarly only exposes URL and withCredentials. Do not place long-lived bearer secrets in query strings, which are easily retained in logs and telemetry. Prefer a secure cookie plus server-side session checks, or exchange an authenticated HTTPS request for a short-lived, single-use connection ticket. A WebSocket handshake is not governed by CORS preflight: validate its Origin header against an exact allowlist to prevent cross-site WebSocket hijacking, then authenticate and authorize each subscription or command. For credentialed cross-origin EventSource, return an exact Access-Control-Allow-Origin value and Access-Control-Allow-Credentials: true, subject to cookie policy. Restrict both APIs with CSP connect-src, validate all messages and sizes server-side, apply quotas, and avoid exposing sensitive detail in close reasons or event payloads.

Allow only explicit realtime destinations with CSP
Content-Security-Policy: default-src 'self'; connect-src 'self' wss://realtime.example.com https://events.example.com
Validate origin and session during a server upgrade
const allowedOrigins = new Set(['https://app.example.com']);

function authorizeUpgrade(request) {
  const originAllowed = allowedOrigins.has(request.headers.origin);
  const session = verifySessionCookie(request.headers.cookie ?? '');
  return originAllowed && session?.permissions.includes('realtime:connect');
}

// Reject the HTTP upgrade unless authorizeUpgrade(request) is true.
Back to quick reference ↑
08

Give every live connection a single owner and stop path

A long-lived connection retains listeners, callbacks, retry timers, server resources, and sometimes credentials. The component or application that creates it should also remove its listeners, clear heartbeat and reconnect timers, and call close. EventSource.close permanently stops that instance's automatic reconnect. WebSocket.close starts a closing handshake and does not discard data already queued by send, so stop producers before closing and do not wait indefinitely for delivery. pagehide is a useful best-effort cleanup signal for page-owned connections, but navigation, process termination, and network loss can prevent clean shutdown; servers must detect timeouts and expire abandoned sessions. Decide separately whether a hidden page should remain connected—visibilitychange can pause expensive UI work without pretending the network session ended.

Dispose transport listeners, timers, and connections together
function ownRealtimeConnection(socket, events) {
  const listeners = new AbortController();
  const heartbeatTimer = setInterval(sendHeartbeat, 20_000);

  socket.addEventListener('message', handleSocketMessage, { signal: listeners.signal });
  events.addEventListener('message', handleServerEvent, { signal: listeners.signal });

  return function dispose() {
    listeners.abort();
    clearInterval(heartbeatTimer);
    clearTimeout(reconnectTimer);
    events.close();
    if (socket.readyState < WebSocket.CLOSING) {
      socket.close(1000, 'owner disposed');
    }
  };
}
Back to quick reference ↑

Sources and further reading

References

Authoritative documentation used to verify and expand this cheat sheet.

  1. WHATWGWebSockets Standardwebsockets.spec.whatwg.org
  2. WHATWGHTML Standard: Server-sent eventshtml.spec.whatwg.org
  3. Internet Engineering Task ForceRFC 6455: The WebSocket Protocoldatatracker.ietf.org
  4. World Wide Web ConsortiumContent Security Policy Level 3: connect-srcw3.org
  5. MDN Web DocsWebSocket APIdeveloper.mozilla.org
  6. MDN Web DocsUsing server-sent eventsdeveloper.mozilla.org

Help us improve

Found a typo or missing example?

Tell us what would make this cheat sheet clearer, more complete, or more useful.

Share feedback