The essentials
Quick reference
One focused task per row. Jump to the related section for complete, working examples.
| Use | Syntax | Examples |
|---|---|---|
| Open a WebSocket | const socket = new WebSocket('wss://realtime.example.com/v1') | View examples |
| Open an event stream | const events = new EventSource('/api/events') | View examples |
| Include cross-origin credentials | const events = new EventSource('https://events.example/stream', { withCredentials: true }) | View examples |
| Offer subprotocols | const socket = new WebSocket(url, ['realtime.v2', 'realtime.v1']) | View examples |
| Wait for an open connection | socket.addEventListener('open', handleOpen, { once: true }) | View examples |
| Check the ready state | if (socket.readyState === WebSocket.OPEN) socket.send(payload) | View examples |
| Start a normal close | socket.close(1000, 'view unmounted') | View examples |
| Send a JSON message | socket.send(JSON.stringify({ type: 'subscribe', topic: 'orders' })) | View examples |
| Receive a WebSocket message | socket.addEventListener('message', event => routeMessage(event.data)) | View examples |
| Receive binary as ArrayBuffer | socket.binaryType = 'arraybuffer' | View examples |
| Send binary data | socket.send(new Uint8Array([1, 2, 3])) | View examples |
| Inspect queued outgoing bytes | const queuedBytes = socket.bufferedAmount | View examples |
| Apply an outgoing high-water mark | if (socket.bufferedAmount
< 262144) socket.send(nextMessage) | View examples |
| Handle default SSE messages | events.addEventListener('message', event => render(JSON.parse(event.data))) | View examples |
| Handle a named SSE event | events.addEventListener('order.updated', handleOrderUpdate) | View examples |
| Read the last event ID | const cursor = event.lastEventId | View examples |
| Resume from an SSE cursor | Last-Event-ID: 1042 | View examples |
| Set the SSE retry delay | retry: 5000 | View examples |
| Send an SSE comment heartbeat | : heartbeat | View examples |
| Inspect EventSource state | if (events.readyState === EventSource.CONNECTING) showReconnecting() | View examples |
| Stop EventSource reconnects | events.close() | View examples |
| Restrict realtime endpoints | Content-Security-Policy: connect-src 'self' wss://realtime.example.com https://events.example.com | View 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
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.
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}`);
} 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.
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
});
}); 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.
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);
}); 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.
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); 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.
Content-Type: text/event-stream
Cache-Control: no-cache
id: 1042
event: order.updated
data: {"id":"A17","status":"packed"}
retry: 5000
: heartbeat
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');
}
}); 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.
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(); 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.
Content-Security-Policy: default-src 'self'; connect-src 'self' wss://realtime.example.com https://events.example.com 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. 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.
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');
}
};
} Sources and further reading
References
Authoritative documentation used to verify and expand this cheat sheet.
- WHATWGWebSockets Standardwebsockets.spec.whatwg.org
- WHATWGHTML Standard: Server-sent eventshtml.spec.whatwg.org
- Internet Engineering Task ForceRFC 6455: The WebSocket Protocoldatatracker.ietf.org
- World Wide Web ConsortiumContent Security Policy Level 3: connect-srcw3.org
- MDN Web DocsWebSocket APIdeveloper.mozilla.org
- 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.



