WebSocket and SSE

31 min read
On this page

WebSocket provides a persistent bidirectional message channel; SSE sends text events within HTTP responses. Both allow the server to proactively push data; the choice depends on interaction direction, message types, recovery semantics, and infrastructure support, rather than "who can push."

For example, an order page mainly receives status changes, while client modifications to orders still use standard HTTP; SSE can cover these needs. Collaborative editing requires frequent bidirectional operations and custom message protocols, making WebSocket more natural. Regardless of the choice, recovery after disconnection and handling slow consumers require application-level design.

QuestionKey Points to Check
Does WebSocket always return 101?HTTP/1.1 Upgrade and h2/h3 extended CONNECT
Does one send equal one network packet?Messages, frames, and transport segmentation
Can mask protect secrets?Masking is not encryption; confidentiality relies on TLS, etc.
Does Pong prove application task health?Protocol stack liveness is separate from business processing
Does SSE reconnection guarantee no lost events?IDs, server logs, and consumption acknowledgments
Page becomes slower and memory growsSend queues, backpressure, and message merging

WebSocket Connection: Confirm Protocol Before Sending Messages

HTTP/1.1 handshakes use GET, Upgrade, and Connection fields. The client provides a random 16-byte value in base64 format as the Sec-WebSocket-Key. The server returns 101 with the correct Sec-WebSocket-Accept; the subprotocol must be chosen from the candidates provided by the client.

The Accept calculation concatenates the text value of the Key field with a specified GUID, then performs SHA1 and base64 encoding; it does not decode the Key into 16 bytes first before concatenation. It proves the server understands this protocol upgrade and does not assume certificate authentication or confidentiality responsibilities.

import base64
import hashlib

key = 'dGhlIHNhbXBsZSBub25jZQ=='
guid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
assert len(base64.b64decode(key)) == 16
accept = base64.b64encode(hashlib.sha1((key + guid).encode('ascii')).digest()).decode('ascii')
assert accept == 's3pPLMBiTxaQ9kYGzzhZRbK+xOo='
wrong = base64.b64encode(hashlib.sha1(base64.b64decode(key) + guid.encode('ascii')).digest()).decode('ascii')
assert accept != wrong
print('RFC handshake vector passed:', accept)

In HTTP/2 and HTTP/3, WebSocket can also be established using extended CONNECT under mutually supported conditions, with a successful response being a corresponding 2xx status instead of the HTTP/1.1 101 upgrade. It carries a single HTTP stream, not converting the entire multiplexed connection into WebSocket. RFC 8441, RFC 9220

Messages, Frames, and Masking

A WebSocket message can be fragmented into frames; control frames can be inserted between fragments Text: FIN=0 First segment of the message Ping: FIN=1 Standalone control frame Continuation: FIN=1 Remaining part and end of message The application receives the reassembled message, not each TCP segment The entire text message must be valid UTF-8 Control frame payload is max 125 B and cannot be fragmented; frame boundaries, message boundaries, and transport packet boundaries are different.

The basic frame header includes FIN, extension reserved bits, opcode, MASK, and length encoding. Lengths 0 to 125 are expressed directly; 126 indicates a subsequent 16-bit length; 127 indicates a subsequent 64-bit length with the most significant bit required to be 0; if masking is present, a 4-byte key follows.

Frames sent from client to server must be masked; frames sent from server to client must not be masked, including WebSocket frames within TLS. Each frame requires a new and unpredictable masking key; the fixed key in the example is only used to reproduce standard teaching vectors.

payload = b'Hello'
mask = bytes.fromhex('37fa213d')
masked = bytes(v ^ mask[i % 4] for i, v in enumerate(payload))
wire = bytes([0x81, 0x80 | len(payload)]) + mask + masked
assert wire.hex() == '818537fa213d7f9f4d5158'
assert bytes(v ^ mask[i % 4] for i, v in enumerate(masked)) == payload
assert len(wire) == 11  # 2 B basic header + 4 B mask + 5 B content
assert 2 + len(payload) == 7  # Server frame with same content, no mask
print('Client / Server short frame bytes:', len(wire), 2 + len(payload))

XOR masking cannot prevent observers from restoring content; it is used for protection against specific infrastructure attacks, and confidentiality requires transport protection like wss. FIN ends the sequence of message fragments, and control frames can be inserted between them; control frames cannot be fragmented, and their payload does not exceed 125 B. UTF-8 validity for text messages is checked under reassembly semantics; it is not required that each fragment end exactly at a character boundary. RFC 6455: Frames and Fragmentation

Heartbeats, Closures, and Business Acknowledgments

Ping/Pong checks if the protocol channel has a response. Pong responds to Ping data as requested, but WebSocket libraries can handle it independently when the application thread is busy, so Pong does not guarantee database availability, room subscription validity, or that business messages have been committed.

The browser WebSocket API does not provide a JavaScript method to directly send protocol Pings. If application heartbeats are needed, define business messages with sequence numbers and timestamps, and have the application process and reply; do not mistakenly assume a JSON string named "ping" is an opcode 9 control frame. WebSockets API

Closure handshakes use Close frames; if a status code is present, write 2 bytes in network byte order first, followed by an optional UTF-8 reason. 1000 indicates normal closure; 1006 is used locally to indicate abnormal closure and cannot be sent online as a Close frame status code. Closure reasons also do not equate to the final result of a specific business message.

Application acknowledgments should use independent message IDs, submission results, or processed sequence numbers. For example, "Operation 17 received" and "Operation 17 persisted" are different acknowledgment points; after connection disconnection, query or replay based on operation ID, rather than just checking if the last send returned.

SSE is an Event Format, Not Dependent on Chunked

SSE responses use Content-Type: text/event-stream, with content as UTF-8 text, where field lines and empty lines form events. In HTTP/1.1, chunked encoding can be used to carry them; in HTTP/2/3, they are carried by the corresponding DATA stream; it cannot be defined as "must use HTTP chunked."

LineMeaning
data: ...Event data; multiple lines are concatenated with newlines
event: alertEvent type; defaults to message if not specified
id: 42Updates event ID, used for recovery position
retry: 3000Sets reconnection time in milliseconds
: keepaliveComment, does not dispatch business events
Empty lineTriggers dispatch of the complete event

The following generates an event with two data lines and a comment heartbeat. Empty lines in the event format cannot be replaced by ending the TCP connection; content that is not completed with a separator at the end will not be automatically delivered as a complete event.

wire = ('id: 42\nevent: alert\ndata: first line\ndata: second line\n\n'
        ': keepalive\n\n').encode('utf-8')
first, heartbeat, trailing = wire.decode('utf-8').split('\n\n')
fields = {}
values = []
for line in first.splitlines():
    name, value = line.split(':', 1)
    value = value[1:] if value.startswith(' ') else value
    if name == 'data':
        values.append(value)
    else:
        fields[name] = value
assert fields == {'id': '42', 'event': 'alert'}
assert '\n'.join(values) == 'first line\nsecond line'
assert heartbeat.startswith(':') and trailing == ''
print('Event type / ID / Multi-line data:', fields['event'], fields['id'], repr('\n'.join(values)))

This example only verifies LF format and field concatenation for this case, not a complete EventSource parser. Real formats also define handling rules for CRLF/CR, empty IDs, unknown fields, and NULs. HTML: Event Stream Parsing

Reconnection and Recovery: IDs Do Not Automatically Generate Historical Logs

SSE: the client supplies a cursor; the server replays events Client Event Service Event id=42: Client parses and dispatches Reconnect after connection interruption: Last-Event-ID: 42 Logs still retain subsequent events: Replay 43, 44... per contract Cursor has exceeded the retention window: Return an explicit resynchronization path Browser event dispatch is not a business submission confirmation; consumers still need deduplication, and gap handling requires application agreements.

EventSource attempts to reconnect after a recoverable disconnection, carrying the recorded Last-Event-ID; the server needs to implement replay by ID itself. If logs are only retained for one hour and the client is offline for two hours, a clear process for fetching a snapshot and continuing subscription should be provided, rather than silently starting from the latest event.

Browser dispatching events and updating positions does not mean the business consumer has persisted the submission. Page crashes, duplicate replays, and processing failures may require application-level deduplication and checkpoints; relying solely on EventSource cannot provide exactly-once business delivery. Recreating the EventSource object does not automatically retain the recovery state of the old object.

SSE comments can provide link activity; automatic reconnection itself is not a heartbeat. The server can also end the reconnection intent with 204, and the client can call close; the application needs to distinguish between temporary network disconnection, credential expiration, and permanent service stop. HTML: EventSource

Why can Last-Event-ID still require a fresh snapshot?

Put the cursor two positions before retention: gold events cannot be retrieved. One position before retention makes the next required event available. Real systems also coordinate consumption and snapshot-to-subscription consistency.

Preparing the visual
Why can Last-Event-ID still require a fresh snapshot?

A cursor records position; replay capability comes from retained history. Continuing from the oldest available event can silently omit evicted changes.

Browser Authentication and Origin Checks

Browser WebSocket constructors and native EventSource do not provide interfaces for arbitrary custom request headers; you cannot pretend to directly add Authorization in examples. EventSource can use applicable cookies and withCredentials settings; cross-origin reads follow CORS. If you choose to use fetch to read the event stream yourself, parsing, recovery, and reconnection must also be handled by code.

WebSocket handshakes do not rely on standard CORS allow headers to authenticate origins. When using browser cookies for authentication, the server should verify Origin and business authorization to prevent other sites from using user credentials to establish connections; non-browser clients can self-report Origin, so it also cannot replace user authentication.

How to handle existing long-lived connections after credentials expire needs protocol agreement: re-authentication, disconnect and reconnect, or permission validation per message. Checking only once at handshake while allowing the connection to live indefinitely makes identity revocation and permission updates difficult to enforce.

Backpressure and Proxies: Being Able to Push Does Not Mean Unlimited Queuing

The classic WebSocket API's send enqueues into a send queue; bufferedAmount can help observe unsent data, but it does not automatically provide a complete waitable backpressure protocol for business producers. The receiving side must also limit message size and consumption queues to prevent slow processing from causing memory growth.

For example, a dashboard updates 100 times per second but only needs to display the latest state; intermediate updates can be merged by key. Audit events cannot be arbitrarily dropped, so queue limits, persistent logs, and slow consumer recovery processes should be set. These two requirements should not use the same "drop on full queue" strategy.

Proxy buffering may cause SSE to accumulate into a batch before delivery, and idle timeouts may close long-lived connections with no data. Heartbeat intervals should consider the shortest idle limit across the entire chain and network jitter, rather than blindly copying 15 seconds; deployment draining also needs to define maximum wait times and reconnection backoff to avoid reconnection storms during simultaneous restarts.

permessage-deflate can reduce WebSocket text volume, but increases trade-offs in CPU, state, and compression information leakage; whether context takeover retains history across messages is determined by negotiation, and "enabling compression" should not be treated as an unconditional benefit. RFC 7692

Selection Comparison

RequirementWebSocketSSE
Frequent message exchange between client and serverDirect bidirectional channelClient operations usually go via HTTP
Raw binary contentSupports binary messagesText format, requires separate encoding or download
Browser recovery supportApplication implements reconnection and recoveryNative reconnection and Last-Event-ID, replay still implemented by server
Infrastructure requirementsSupports actual Upgrade/CONNECT carryingSupports long responses and timely flushing
Business reliabilityCustom ACK, deduplication, and replayAlso requires consumption semantics, deduplication, and logs

Small-scale, low-frequency updates might still use polling; having only server push needs does not automatically dictate WebSocket. Clarify latency, message direction, loss tolerance, and recovery costs first, then choose the carrying method.

Further Reading