26 min read
Event-Driven Systems5,541 words

Server-Sent Events (SSE) & WebSocket

What this is: a practical, interview-ready deep dive into the two mainstream ways to make an HTTP server push to a client — when to reach for each, how they work on the wire, how to build them in Spring Boot (Kotlin + Java), and the scaling/operability questions that separate a senior answer from a Staff+ one. Code targets Spring Boot 3.2+ and 4.0 (GA Nov 2025); the SSE/WebSocket programming model is unchanged across those versions, so everything here is version-stable unless flagged.


The one mental model everything derives from#

HTTP is client-pull and half-duplex: the client asks, the server answers, the exchange ends. Every "real-time" feature is a workaround for that one limitation — letting the server initiate data flow. There are exactly three families of workaround, and they line up on a single axis: how much of HTTP do you keep?

  1. Keep all of HTTP → poll. The client keeps asking. Regular polling (ask on a timer) or long-polling (server holds the request open until it has something, then the client immediately re-asks). The client is still pulling; you've just made the pulls frequent or lazy.
  2. Keep the HTTP connection and its semantics, stream one direction → SSE. A single HTTP response that never closes. The server writes a stream of UTF-8 text events down it. Server → client only. It is still an HTTP response — so it inherits auth headers, cookies, HTTP/2 multiplexing, standard load balancers, compression, curl, and browser-native reconnection.
  3. Throw HTTP away after a handshake → WebSocket. Start as HTTP, send Upgrade: websocket, get 101 Switching Protocols, and from that point the socket is a raw full-duplex, binary-or-text, framed TCP pipe. You get everything — and you now own everything HTTP used to give you for free: auth, reconnection, routing, backpressure, heartbeats.

The rule that makes you sound senior: Direction decides the transport. Server→client only → SSE. Both directions, interactively and continuously → WebSocket. Neither continuously → don't hold a connection at all, poll. Pick the least powerful option that satisfies your data-flow direction, because every step up the ladder buys capability with operational complexity you then own forever.

The corollary — and the single most common junior mistake — is reaching for WebSocket reflexively. Most features labelled "real-time" are actually unidirectional server push: notifications, live dashboards, progress bars, price tickers, log tailing, LLM token streaming, a live charging-session map. All of those are SSE's sweet spot. WebSocket earns its cost only when the client also needs to stream continuously: chat, collaborative editing, multiplayer games, live cursors, trading order entry.

The trade you are really making: WebSocket exchanges the entire HTTP ecosystem for full duplex and binary frames. Only pay that price if you actually need full duplex.


1. The problem space and the decision ladder#

Before either technology, know the alternatives you're beating, because "why not just poll?" is a guaranteed follow-up.

ApproachHow it worksServer→client latencyCostWhen it's actually right
Regular pollingClient hits GET /updates every N secondsUp to N secondsWasteful: most requests return "nothing new"; N× request overheadLow-frequency data, N can be large (dashboards refreshing every 30–60s), simplicity wins
Long-pollingClient requests; server holds it open until data or timeout, then client re-requestsNear-instantA held request per client (like SSE) but with re-request churn and header overhead each cycleFallback when SSE/WS can't traverse infrastructure; still the fallback SockJS uses
SSEOne long-lived HTTP response; server streams text eventsNear-instantOne held connection per client; cheap on HTTP/2Server→client push. The default for real-time.
WebSocketHTTP upgrade → raw full-duplex framed TCPNear-instant both waysOne stateful connection per client + you own the protocol concernsBidirectional, interactive, high-frequency both ways.

Decision ladder (say this out loud in an interview):

  1. Does the client need to send continuously/interactively, not just occasional requests? No → SSE. Yes → WebSocket.
  2. Is the update frequency low enough that a 15–60s delay is fine? Yes → just poll; don't hold a connection.
  3. Do you need to push binary (audio, video, protobuf frames) with minimal per-message overhead? → WebSocket (SSE is UTF-8 text only).
  4. Is your infrastructure hostile to long-lived connections (legacy proxies, strict L4 timeouts, serverless with short execution caps)? → reconsider; long-poll or a hosted realtime service may be pragmatic.

Notice SSE sits above WebSocket in the ladder on purpose: it's the higher-default because it's cheaper to operate.


2. SSE — core concepts#

2.1 What it is#

SSE is a W3C/WHATWG standard (part of the HTML EventSource spec), not a separate network protocol. It is an ordinary HTTP response with Content-Type: text/event-stream that the server simply never finishes. The body is a stream of UTF-8 text "events" in a trivial line-based format. Because it's just HTTP, a browser has a built-in client (EventSource) and you can literally curl an SSE endpoint and watch events scroll by.

2.2 The wire format (know this cold — it demystifies everything)#

Each event is a set of field: value lines, terminated by a blank line (\n\n). Four fields exist:

event: session-update id: 1042 retry: 3000 data: {"sessionId":"42","kWh":12.4,"state":"CHARGING"} :ping event: session-update id: 1043 data: {"sessionId":"42","kWh":12.9,"state":"CHARGING"}
  • data: — the payload. Multiple data: lines concatenate with \n. This is the only required field.
  • event: — a named event type. On the client, es.addEventListener("session-update", …). Omit it and it arrives on the default onmessage/"message" handler.
  • id: — an opaque event ID. The browser remembers the last id it received and sends it back on reconnect (see resumption). Set it to a monotonic sequence or an event-log offset.
  • retry: — reconnection delay hint in milliseconds; tells the client's EventSource how long to wait before retrying after a drop.
  • A line starting with : is a comment — ignored by the client. This is the idiomatic heartbeat/keep-alive (:ping) that stops idle-timeout proxies from killing the connection and lets you detect a dead peer.

2.3 The browser client and automatic reconnection — SSE's superpower#

javascript
const es = new EventSource("/api/sessions/42/stream", { withCredentials: true }); es.addEventListener("session-update", e => render(JSON.parse(e.data))); es.onerror = () => { /* the browser is ALREADY reconnecting for you */ };

The killer feature: EventSource reconnects automatically, and on reconnect it sends the last id it saw as a Last-Event-ID HTTP header. If your server keeps a short replay buffer (or the ids are event-log offsets), you resume exactly where the client left off — no gaps. WebSocket has nothing like this built in; you build reconnection and resumption yourself. For anything where missing an update matters, this is a decisive advantage.

The server sees the resume request as a normal header:

GET /api/sessions/42/stream HTTP/1.1 Accept: text/event-stream Last-Event-ID: 1043

…and replays events > 1043. This gives you at-least-once delivery with resumption almost for free — combine with client-side dedupe by id for idempotency.

2.4 The limitations you must name#

  • Unidirectional (server → client only). The client sends via ordinary, separate HTTP requests. This is fine — and often a feature, because those requests are normal REST calls with normal auth/validation.
  • UTF-8 text only. No binary frames. Binary must be Base64-encoded into data: (≈33% bloat). If you're pushing audio/video/protobuf, that's your signal to use WebSocket.
  • The HTTP/1.1 six-connection cap — the classic gotcha. Browsers allow only ~6 concurrent connections per origin on HTTP/1.1. Each EventSource holds one open. Open the app in a few tabs and you exhaust the budget — new SSE streams and ordinary API calls to that origin hang. HTTP/2 fixes this by multiplexing many streams over one connection (server-advertised SETTINGS_MAX_CONCURRENT_STREAMS, commonly ~100+). Serving SSE over HTTP/2 is a best practice, not a nice-to-have. (Mitigations without HTTP/2: funnel all tabs through one SharedWorker/BroadcastChannel, or consolidate many logical streams into one connection.)
  • Proxy buffering breaks it. A reverse proxy that buffers the response (nginx by default) will hold events and deliver them in bursts — or never. You must disable buffering (§10).

3. SSE in Spring — two stacks, one concept#

Spring gives you SSE in both stacks, and the choice reveals your understanding of the connection cost model.

  • Spring MVC (servlet, blocking): SseEmitter. One thread is tied up per active send. Simple mental model; scales via virtual threads (Boot 3.2+ / JDK 21) — flip spring.threads.virtual.enabled=true and "one thread per connection" stops being a scaling wall.
  • Spring WebFlux (reactive, non-blocking): return a Flux<ServerSentEvent<T>> (Java) or Flow<ServerSentEvent<T>> (Kotlin coroutines). No thread per connection; backpressure is end-to-end through Reactive Streams.

3.1 Spring MVC — SseEmitter (Java)#

java
import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; import java.time.Duration; @RestController @RequestMapping("/api/sessions") class SessionStreamController { private final SessionStreamRegistry registry; SessionStreamController(SessionStreamRegistry registry) { this.registry = registry; } // produces=text/event-stream is what makes Spring keep the response open and stream @GetMapping(path = "/{id}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) SseEmitter stream(@PathVariable String id, @RequestHeader(value = "Last-Event-ID", required = false) String lastEventId) { SseEmitter emitter = new SseEmitter(Duration.ofMinutes(30).toMillis()); // per-connection timeout registry.add(id, emitter, lastEventId); // register + replay events after lastEventId emitter.onCompletion(() -> registry.remove(id, emitter)); emitter.onTimeout(() -> registry.remove(id, emitter)); // Spring calls complete() after this emitter.onError(ex -> registry.remove(id, emitter)); return emitter; } }

Pushing an event (from a Kafka listener, a domain service, wherever the update originates):

java
void push(SseEmitter emitter, long seq, SessionUpdate update) { try { emitter.send(SseEmitter.event() .id(Long.toString(seq)) // → client's Last-Event-ID on reconnect .name("session-update") // → the "event:" field .reconnectTime(3_000) // → the "retry:" field (ms) .data(update, MediaType.APPLICATION_JSON)); } catch (IOException e) { emitter.completeWithError(e); // client vanished → triggers onError → cleanup } } // heartbeat every 15s so idle-timeout proxies don't kill the stream: void heartbeat(SseEmitter emitter) throws IOException { emitter.send(SseEmitter.event().comment("ping")); // renders as ":ping" }

The blocking-cost caveat (say this): emitter.send() blocks the calling thread until bytes hit the socket send buffer. A slow client therefore holds a thread. With a platform-thread pool, enough slow clients exhaust it — a self-inflicted DoS. Fixes, in order of preference: run on virtual threads; or send from a bounded executor with a send-timeout and drop/close laggards; or move to WebFlux where a slow client can't pin a thread.

3.2 Spring WebFlux — reactive SSE (Kotlin, coroutine Flow)#

kotlin
import org.springframework.http.MediaType import org.springframework.http.codec.ServerSentEvent import org.springframework.web.bind.annotation.* import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import java.time.Duration @RestController class SessionStreamController(private val sessions: SessionService) { @GetMapping("/api/sessions/{id}/stream", produces = [MediaType.TEXT_EVENT_STREAM_VALUE]) fun stream(@PathVariable id: String): Flow<ServerSentEvent<SessionUpdate>> = sessions.updates(id) // a Flow<SessionUpdate> off Kafka / a domain sink .map { u -> ServerSentEvent.builder(u) .id(u.sequence.toString()) .event("session-update") .retry(Duration.ofSeconds(3)) .build() } }

3.3 Spring WebFlux — reactive SSE (Java, Flux) with a heartbeat merged in#

java
import org.springframework.http.MediaType; import org.springframework.http.codec.ServerSentEvent; import reactor.core.publisher.Flux; import java.time.Duration; @GetMapping(path = "/api/sessions/{id}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) Flux<ServerSentEvent<SessionUpdate>> stream(@PathVariable String id) { Flux<ServerSentEvent<SessionUpdate>> updates = sessions.updates(id) .map(u -> ServerSentEvent.builder(u) .id(Long.toString(u.sequence())) .event("session-update") .build()); Flux<ServerSentEvent<SessionUpdate>> heartbeat = Flux.interval(Duration.ofSeconds(15)) .map(t -> ServerSentEvent.<SessionUpdate>builder().comment("ping").build()); return Flux.merge(updates, heartbeat); }

Why WebFlux is the better SSE host at scale: demand flows backwards. If the client is slow, TCP's window fills, Reactor stops requesting from the source, and the source slows down — no thread pinned, no unbounded buffer. The catch: if the source is a hot stream (a Sinks.Many fed by Kafka that can't be slowed), you must pick an overflow strategy (onBackpressureBuffer with a cap, …Drop, …Latest) so one slow consumer can't OOM the node. Naming that trade-off is a Staff-level signal.

3.4 The fan-out registry (the part tutorials skip)#

Whichever stack, you need a structure mapping subscription key → live emitters on this node, cleaned up on completion/timeout/error. Keep it concurrent and be honest that it is per-node — cross-node fan-out needs a backplane (§7).

kotlin
@Component class SessionStreamRegistry { // sessionId -> that session's live emitters ON THIS NODE private val streams = ConcurrentHashMap<String, MutableSet<SseEmitter>>() fun add(id: String, emitter: SseEmitter, lastEventId: String?) { streams.computeIfAbsent(id) { ConcurrentHashMap.newKeySet() }.add(emitter) // if lastEventId != null: replay buffered events with seq > lastEventId here } fun remove(id: String, emitter: SseEmitter) { streams[id]?.remove(emitter) } fun deliver(id: String, update: SessionUpdate) { streams[id]?.forEach { e -> try { e.send(SseEmitter.event().id(update.sequence.toString()) .name("session-update").data(update)) } catch (ex: Exception) { e.completeWithError(ex) } } } }

4. WebSocket — core concepts#

4.1 What it is#

WebSocket is RFC 6455, a genuine separate protocol (ws:// / wss://) that bootstraps over HTTP. After an HTTP handshake it becomes a full-duplex, message-framed connection over a single TCP socket. Both sides send at will; messages can be text or binary; per-message overhead is tiny (a 2–14 byte frame header vs a full HTTP request's headers each time).

4.2 The handshake — where it's still HTTP#

GET /ws/sessions HTTP/1.1 Host: app.example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 Origin: https://app.example.com HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

The Sec-WebSocket-Accept is a fixed hash of the client's key — it proves the server actually spoke WebSocket (not a confused cache). After 101, the bytes on the wire are WebSocket frames, not HTTP. Consequences that matter operationally: your load balancer must be configured to pass the Upgrade (L7 aware, or L4 pass-through), and the Origin header is your one CSRF-style checkpoint at the handshake (§8).

4.3 Frames, masking, control frames#

  • Opcodes: text (0x1), binary (0x2), close (0x8), ping (0x9), pong (0xA), continuation (0x0) for fragmented messages.
  • Masking: every client→server frame must be XOR-masked with a random key (an anti-cache-poisoning measure from the spec). Server→client frames are not masked. Libraries handle this; know it exists.
  • Ping/Pong are protocol-level control frames — the built-in heartbeat. Either side sends ping; the peer must answer pong. This is how you detect a half-open TCP connection (peer vanished without a FIN) and keep intermediaries from timing out an idle socket.
  • Close handshake: a close frame carries a close code1000 normal, 1001 going away (server shutdown), 1006 abnormal (no close frame — the one you see on network drops), 1011 server error, 1013 try again later. Surfacing the right code helps clients decide whether/how fast to reconnect.

4.4 Subprotocols — don't invent your own message semantics#

The handshake can negotiate an application subprotocol via Sec-WebSocket-Protocol. Rather than hand-rolling framing, routing, and ack semantics on top of raw WebSocket, you usually pick a subprotocol: STOMP (Spring's first-class choice — pub/sub destinations, subscriptions, receipts), graphql-ws (GraphQL subscriptions), MQTT over WebSocket (IoT, QoS levels), WAMP (RPC + pub/sub). The senior instinct: raw WebSocket is a transport, not a protocol — choose or design the message layer deliberately.


5. WebSocket in Spring — raw handlers vs STOMP#

Spring offers two levels. Raw WebSocketHandler = you own the message protocol (good for a bespoke binary protocol or a tiny surface). STOMP over WebSocket (@EnableWebSocketMessageBroker) = a full pub/sub messaging model with destinations, subscriptions, user-targeting, and a pluggable broker — this is what you want for anything non-trivial, and crucially it gives you the horizontal-scaling story for free via a broker relay.

5.1 Raw handler (Java) — and the thread-safety trap that ties to concurrency interviews#

java
import org.springframework.web.socket.*; import org.springframework.web.socket.config.annotation.*; import org.springframework.web.socket.handler.ConcurrentWebSocketSessionDecorator; import org.springframework.web.socket.handler.TextWebSocketHandler; import java.util.concurrent.ConcurrentHashMap; @Configuration @EnableWebSocket class RawWsConfig implements WebSocketConfigurer { private final SessionSocketHandler handler; RawWsConfig(SessionSocketHandler handler) { this.handler = handler; } @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(handler, "/ws/sessions") .setAllowedOrigins("https://app.example.com"); // NOT "*" — see §8 (CSWSH) } } @Component class SessionSocketHandler extends TextWebSocketHandler { private final ConcurrentHashMap<String, WebSocketSession> live = new ConcurrentHashMap<>(); @Override public void afterConnectionEstablished(WebSocketSession session) { // WRAP: WebSocketSession.sendMessage is NOT safe for concurrent senders. // The decorator serializes sends AND bounds the outbound buffer (closes slow clients). var safe = new ConcurrentWebSocketSessionDecorator( session, /*sendTimeLimitMs*/ 10_000, /*bufferSizeLimitBytes*/ 512 * 1024); live.put(session.getId(), safe); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { session.sendMessage(new TextMessage("ack:" + message.getPayload())); // client→server inbound } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { live.remove(session.getId()); } }

Interview gold (ties to your concurrency/thread-safety guide): a single WebSocketSession must not be written by two threads at once — concurrent sendMessage calls corrupt the frame stream. In a fan-out service, a Kafka listener thread and a heartbeat scheduler thread will both want to send. Wrap every session in ConcurrentWebSocketSessionDecorator, which serializes sends behind a lock and enforces a bounded send buffer with an overflow policy — so a slow consumer gets disconnected instead of growing an unbounded queue and OOMing the node. That single sentence covers thread safety and backpressure, which is exactly the pairing a Staff interviewer is probing.

5.2 STOMP over WebSocket (Kotlin) — the scalable default#

kotlin
import org.springframework.context.annotation.Configuration import org.springframework.messaging.simp.config.MessageBrokerRegistry import org.springframework.web.socket.config.annotation.* @Configuration @EnableWebSocketMessageBroker class StompConfig : WebSocketMessageBrokerConfigurer { override fun registerStompEndpoints(registry: StompEndpointRegistry) { registry.addEndpoint("/ws") .setAllowedOrigins("https://app.example.com") .withSockJS() // HTTP long-poll fallback for hostile proxies } override fun configureMessageBroker(registry: MessageBrokerRegistry) { registry.setApplicationDestinationPrefixes("/app") // client→server @MessageMapping routes registry.enableSimpleBroker("/topic", "/queue") // in-memory broker: SINGLE NODE ONLY // Horizontally scaled? Replace the line above with a relay to a real broker (the backplane): // registry.enableStompBrokerRelay("/topic", "/queue") // .setRelayHost("rabbitmq").setRelayPort(61613) // .setSystemLogin("app").setSystemPasscode("...") } }
kotlin
import org.springframework.messaging.handler.annotation.* import org.springframework.messaging.simp.SimpMessagingTemplate import org.springframework.stereotype.Controller @Controller class SessionWsController(private val broker: SimpMessagingTemplate) { // Client SENDs to /app/session/42/hello ; return value is published to /topic/session/42 @MessageMapping("/session/{id}/hello") @SendTo("/topic/session/{id}") fun hello(@DestinationVariable id: String, msg: ClientMsg): Ack = Ack("joined $id") // Push from ANYWHERE (a Kafka consumer, a domain service) to all subscribers of a topic: fun broadcast(update: SessionUpdate) = broker.convertAndSend("/topic/session/${update.id}", update) // Push privately to one authenticated user's queue (Spring resolves the user's session): fun toUser(userId: String, update: SessionUpdate) = broker.convertAndSendToUser(userId, "/queue/sessions", update) }

Why STOMP is the pragmatic senior choice: subscriptions/destinations, @SendToUser targeting, message receipts (acks), and — the big one — swapping enableSimpleBroker for enableStompBrokerRelay turns a single-node demo into a horizontally-scalable system with one config change, because the external broker (RabbitMQ/ActiveMQ) becomes the shared backplane every app node relays through.

5.3 Authenticating a WebSocket (a guaranteed follow-up)#

The browser WebSocket constructor cannot set custom headers — so Authorization: Bearer … is off the table from JS. Your options, best-first:

  1. Cookie-borne session on the handshake. The handshake is an HTTP request, so a Secure; HttpOnly; SameSite session cookie rides along and Spring Security authenticates it there. Cleanest for same-site browser apps.
  2. Short-lived ticket. Client calls an authenticated REST endpoint, gets a one-time token, connects with wss://…/ws?ticket=…, server validates and immediately invalidates it. Because the token is single-use and short-lived, the "tokens in URLs get logged" risk (§8) is bounded.
  3. STOMP CONNECT frame header. Put the bearer token in a STOMP header on the CONNECT frame and authenticate in a ChannelInterceptor — auth lives in the messaging layer, not the URL. Common in SPA + STOMP setups.

Authenticate on the handshake, then bind the principal to the session for the connection's life; for long-lived connections consider expiry/re-auth so a revoked token doesn't stay privileged forever.


6. SSE vs WebSocket — the comparison table#

DimensionSSEWebSocket
DirectionServer → client onlyFull duplex
Underlying protocolHTTP (stays HTTP the whole time)HTTP handshake → raw TCP frames (RFC 6455)
PayloadUTF-8 text only (Base64 for binary)Text or binary
Browser clientNative EventSourceNative WebSocket
Auto-reconnectYes, built inNo — you build it
Resume after dropYesLast-Event-ID replayNo — you build it
Custom auth headers from browserLimited (EventSource can't set headers either; cookies ok)No (cookies / subprotocol / ticket)
Per-message overheadSmall text framingMinimal (2–14 byte frame header)
Works with HTTP/2 multiplexingYes (dodges the 6-conn cap)Yes, via RFC 8441 (widely supported ~2026)
Infra friendlinessHigh — it's just HTTPNeeds Upgrade-aware LB/proxy config
Horizontal scalingNeeds a backplane for cross-node fan-outNeeds a backplane; STOMP relay makes it turnkey
Spring APISseEmitter (MVC) / Flux<ServerSentEvent> (WebFlux)WebSocketHandler (raw) / @EnableWebSocketMessageBroker (STOMP)
Best fitNotifications, live dashboards, progress, log/LLM token streaming, live session mapChat, collab editing, games, live cursors, trading entry, binary streams

The compressed version for the whiteboard: SSE is “HTTP that doesn't hang up”; WebSocket is “a socket that started as HTTP.”


7. Scaling to Staff+ — where the interview actually lives#

Anyone can write a single-node demo. The Staff+ conversation is about what happens at N app nodes and M connections. Drive the discussion through these five problems.

7.1 Stateful connections + the fan-out (backplane) problem — the #1 topic#

A long-lived connection is pinned to exactly one app node. Client C is connected to node A. An event for C is produced on node B (a Kafka partition B happens to own, say). B has no socket to C. A single-node registry (§3.4) can't reach across the process boundary. The fix is a backplane: a shared pub/sub every node subscribes to. When any node has an event, it publishes to the backplane; the backplane fans it out to all nodes; each node pushes to whichever of its locally connected clients care.

kotlin
@Service class SessionFanout( private val redis: StringRedisTemplate, private val local: SessionStreamRegistry, // this node's live SSE emitters / WS sessions private val mapper: ObjectMapper, ) : org.springframework.data.redis.connection.MessageListener { // Called by a Kafka consumer or domain service when a session changes. fun publish(update: SessionUpdate) = redis.convertAndSend("session-updates", mapper.writeValueAsString(update)) // Redis delivers to EVERY node; each pushes only to its own locally-connected clients. override fun onMessage(message: org.springframework.data.redis.connection.Message, pattern: ByteArray?) { val update = mapper.readValue(message.body, SessionUpdate::class.java) local.deliver(update.id, update) } }

Backplane options and the trade you're making: Redis pub/sub — simplest, fire-and-forget, no persistence (a node that's down misses the message; fine for "latest state" pushes). Kafka — durable, replayable, ordered per partition; heavier, and every node consuming every topic needs thought (unique consumer group per node so all nodes get all messages). RabbitMQ/ActiveMQ via STOMP relay — Spring wires it for you; the broker does the fan-out and even the client subscription state. Managed (e.g. a hosted realtime/Pusher-style service) — you buy the whole problem away. Say the words "the connection is stateful and node-pinned, so I need a pub/sub backplane to fan out across nodes" and you've cleared the bar.

7.2 The connection-cost model — threads vs event-loop vs virtual threads#

Each connection costs a socket + file descriptor + memory + (maybe) a thread. The C10K/C10M question is "can one box hold 10k / 10M connections?" and the answer is entirely about the threading model:

  • Thread-per-connection (classic blocking servlet + SseEmitter sending inline): a blocked send pins a platform thread (~1 MB stack). A few thousand slow clients exhaust the pool. This is the model juniors accidentally ship.
  • Event-loop / non-blocking (Netty, WebFlux, raw Spring WebSocket on the servlet container's NIO): a handful of threads multiplex tens of thousands of connections. Connections cost memory, not threads. This is why WebFlux/Netty is the default for very high connection counts.
  • Virtual threads (JDK 21+, spring.threads.virtual.enabled=true): keep the simple blocking SseEmitter code and still scale — a blocked virtual thread parks for near-free (~KBs, not MBs). This is the pragmatic 2025+ answer: "I don't need to rewrite in reactive; I put the blocking SSE handler on virtual threads." Know that pre-JDK-24 there was a synchronized-pinning caveat that JEP 491 (JDK 24) removed — so the old "avoid synchronized" advice is now obsolete.

7.3 Backpressure — the slow-consumer failure mode#

If the server produces faster than a client drains, something must give. Unbounded buffering = OOM. Your defenses: WebFlux propagates demand end-to-end (a slow client slows the source) — but for hot sources choose an explicit overflow strategy (onBackpressureBuffer(cap), Drop, Latest). Raw WS: ConcurrentWebSocketSessionDecorator's buffer limit disconnects laggards. Blocking SSE: send with a timeout and drop/close on breach; don't send inline on the producer thread. The universal principle: bound every buffer and decide, explicitly, what to drop when it's full. "Unbounded queue per connection" is a wrong answer.

7.4 Delivery guarantees & ordering#

Default for both is at-most-once — a message in flight during a drop is gone. To do better:

  • SSE: id: + Last-Event-ID + a replayable server buffer or event-log offsetat-least-once with resumption. Dedupe by id on the client for idempotency. This is SSE's strongest card and why it fits event-sourced/CQRS read-model streaming so well.
  • WebSocket: build acks + resend + dedupe yourself, or adopt a subprotocol that has them (STOMP receipts; MQTT QoS 1/2).
  • Ordering holds within one connection/stream (TCP is ordered) but not across reconnects or across a backplane fan-out — carry a monotonic sequence and let the client reorder/detect gaps. Neither transport gives exactly-once; pair with idempotency keys end-to-end.

7.5 The infrastructure gauntlet (LB, timeouts, deploys)#

  • Load balancer: must support the connection type. WebSocket needs Upgrade pass-through (L7-aware or L4). Both benefit from — and reconnection-with-affinity may requiresticky sessions.
  • Idle timeouts: LBs/proxies kill idle connections (commonly 60s). Heartbeats (:ping for SSE, ping/pong for WS) keep them alive and detect dead peers. Tune the LB idle timeout above your heartbeat interval.
  • Graceful shutdown / rolling deploys: every deploy tears down every connection on a node. Clients reconnect en masse — a thundering herd. Mitigate with jittered reconnect (retry: + client jitter), connection draining on shutdown, and — for SSE — Last-Event-ID resume so the reconnect is gap-free. A Staff answer treats "we deploy 6× a day, so every connection is torn down 6× a day" as a normal, designed-for event, not an incident.

8. Security#

  • Always wss:// / https://. Plaintext ws:// is trivially intercepted; browsers also block mixed content.
  • Validate the Origin header on the WebSocket handshake — CSWSH. WebSocket is not subject to the same-origin policy the way fetch is; a malicious page can open a WebSocket to your origin and, if you authenticate purely by cookie, ride the victim's session (Cross-Site WebSocket Hijacking). Defense: allow-list origins (setAllowedOrigins("https://app.example.com"), never "*" with credentials) and/or require a CSRF token / ticket on connect. SSE via EventSource is CORS-governed, but still lock down origins and don't rely on ambient cookies alone for sensitive streams.
  • Don't put long-lived secrets in the URL. Query-string tokens land in access logs, proxy logs, and browser history. If you must pass a token in the URL (the WebSocket-can't-set-headers problem), make it a single-use, short-TTL ticket.
  • Bound everything: max message size, max frames/sec, max connections per user/IP. An unbounded inbound message or a connection-flood is a cheap DoS. Rate-limit the handshake too.
  • Authenticate on connect, and reconsider on long connections. A token valid at handshake can be revoked mid-connection; for high-value streams, enforce a max session lifetime or periodic re-auth.
  • Validate every inbound message exactly as you would a REST body — a WebSocket is an unauthenticated-by-default write path into your system until you say otherwise.

9. The 2026 landscape — alternatives you should name#

Showing you know the neighbours signals seniority (all current as of mid-2026):

  • SSE over HTTP/2 — the standard way to defeat SSE's 6-connection cap; multiplex many streams over one connection. Prefer it.
  • WebSocket over HTTP/2 (RFC 8441) — now broadly supported (Chrome/Edge/Firefox/Safari). Lets a WebSocket share an HTTP/2 connection instead of monopolizing one.
  • WebSocket over HTTP/3 (RFC 9220) — published 2022 but still has no production browser/server implementations as of 2026; no practical impact yet because HTTP/1.1/2 WebSockets are mature.
  • WebTransport (over HTTP/3 / QUIC) — the "next WebSocket": multiple independent streams (no head-of-line blocking) plus unreliable datagrams, ideal for games/media/low-latency. As of 2026 it reached Baseline — supported across Chromium (Chrome/Edge 97+), Firefox (default since v114, 2023), and Safari (2026). Verdict: now broadly available but still younger and less battle-tested than WebSocket, which stays the safe default — reach for WebTransport in latency-critical niches (real-time media, gaming) and keep a WebSocket fallback for reach.
  • RSocket — a reactive binary protocol (runs over TCP or WebSocket) with four interaction models built in: request/response, request/stream, channel (bidi stream), fire-and-forget — with backpressure at the protocol level. First-class Spring support (@MessageMapping over RSocket). Worth naming in a Spring shop when you need bidirectional streaming with backpressure and don't want to hand-roll it on raw WebSocket.
  • gRPC server-streaming / bidi-streaming — great service-to-service (HTTP/2, binary, typed); browser needs gRPC-Web and can't do client-streaming from the browser. Use between backends, not usually browser↔server.
  • GraphQL subscriptions — real-time in a GraphQL API; transported over WebSocket (graphql-ws) or increasingly SSE. It's a layer on these transports, not a competitor.
  • Long-polling / SockJS fallback — still the safety net when infrastructure forbids long-lived connections.

One-liner: "SSE or WebSocket for browser↔server today; RSocket when I want reactive bidi with backpressure inside a Spring estate; WebTransport is Baseline as of 2026 for latency-critical media/gaming but WebSocket stays the safe default; gRPC streaming stays service-to-service."


10. Problems & how to solve them#

Problem: SSE works on localhost, but behind nginx events arrive in bursts or never. Root cause: the reverse proxy is buffering the response and/or downgrading to HTTP/1.0. Fix: disable buffering for the route — proxy_buffering off;, send header X-Accel-Buffering: no, Cache-Control: no-cache, proxy_http_version 1.1; and clear Connection. Same class of bug on other proxies/CDNs: find the "don't buffer streaming responses" switch.

Problem: after opening the app in a few tabs, SSE streams (and other calls to the origin) hang. Root cause: the HTTP/1.1 ~6-connections-per-origin cap — each EventSource consumes one. Fix: serve over HTTP/2 (multiplexing). Interim: funnel tabs through a SharedWorker/BroadcastChannel, or consolidate streams onto one connection.

Problem: WebSocket (or SSE) connections silently die after ~60s of quiet. Root cause: an LB/proxy idle timeout closing the socket. Fix: application-level heartbeats (WS ping/pong; SSE :ping comment) below the timeout, and raise the LB idle timeout above the heartbeat interval.

Problem: real-time works on one node, breaks after scaling to three. Root cause: connections are node-pinned; an event produced on node B can't reach a client connected to node A — no backplane. Fix: a pub/sub backplane (Redis/Kafka) or a STOMP broker relay (RabbitMQ/ActiveMQ); each node pushes to its local connections on receipt.

Problem: node OOMs / GC-thrashes under load with many slow clients. Root cause: unbounded per-connection buffers and/or thread-per-connection blocking on slow sends. Fix: bound every buffer with an overflow policy (ConcurrentWebSocketSessionDecorator limits; Reactor onBackpressureBuffer(cap)), send with timeouts and drop/close laggards, and move blocking SSE onto virtual threads or WebFlux.

Problem: garbled/interleaved WebSocket frames or intermittent protocol errors under load. Root cause: two threads writing the same WebSocketSession concurrently (e.g. a Kafka listener + a heartbeat scheduler). Fix: wrap in ConcurrentWebSocketSessionDecorator (serializes sends); never share a raw session across senders.

Problem: "reconnect storm" hammers the fleet right after every deploy. Root cause: all clients drop at once and reconnect simultaneously without jitter. Fix: jittered reconnect delay (retry: + client-side randomization), connection draining on graceful shutdown, and Last-Event-ID resume so reconnects are cheap and gap-free.

Problem: can't send Authorization: Bearer on a browser WebSocket. Root cause: the WebSocket API can't set custom headers. Fix: authenticate the handshake via a Secure;HttpOnly session cookie, a single-use short-TTL ticket in the URL, or a token in the STOMP CONNECT frame validated by a ChannelInterceptor.

Problem: a malicious site opens an authenticated WebSocket to your API. Root cause: CSWSH — WebSocket isn't guarded by same-origin the way fetch is. Fix: allow-list Origin on the handshake (never "*" with credentials) and require a CSRF token / ticket on connect.


11. Interview framing — how to sound Staff+#

  • Lead with direction, not technology. "What direction does the data flow, and how often?" → then the ladder. Naming SSE as the default and WebSocket as the earned choice immediately reads as senior; reflexively reaching for WebSocket reads as junior.
  • State the core trade in one line: "WebSocket buys full duplex and binary by leaving the HTTP ecosystem — so I only pay it when the client also streams continuously."
  • Volunteer the scaling problem before you're asked: "These connections are stateful and node-pinned, so beyond one node I need a pub/sub backplane — Redis for fire-and-forget, Kafka when I need durability/replay, or a STOMP broker relay so Spring wires the fan-out for me."
  • Show the cost model: thread-per-connection vs event-loop vs virtual threads; "I can keep blocking SseEmitter code and still scale by turning on virtual threads (JDK 21+), rather than rewriting in reactive."
  • Show the failure modes: proxy buffering, the HTTP/1.1 6-connection cap fixed by HTTP/2, idle-timeout heartbeats, slow-consumer backpressure with bounded buffers, and the WebSocketSession thread-safety trap (ties straight into a concurrency question).
  • Show delivery semantics: at-most-once by default; SSE's Last-Event-ID resume gives at-least-once with replay; exactly-once needs idempotency keys.
  • Name the neighbours: HTTP/2 SSE, RSocket for reactive bidi with backpressure, WebTransport (now Baseline as of 2026) for latency-critical media/gaming, gRPC streaming for service-to-service.

Tie it to your domain (use these as ready examples):

  • Live charging-session map (DCS): a dashboard that watches sessions update in real time is pure server→client push → SSE. Reactive Flux<ServerSentEvent> off the session-update stream; id: = the CQRS read-model/event-log offset, so a dropped dashboard resumes with Last-Event-ID and never shows a stale gap — a clean marriage of SSE and your event-sourced read models. HTTP/2 so a control-room with many panels doesn't hit the 6-connection cap. Fan-out across nodes via a Redis backplane (or Kafka, since the updates already flow through it).
  • OCPI / roaming-hub operational events → operator UIs: again unidirectional notifications → SSE, resumable, cheap to operate.
  • When you'd actually pick WebSocket here: an interactive operator console that sends commands continuously (remote start/stop bursts, live filtering that streams both ways) — then STOMP over WebSocket with a RabbitMQ relay as the backplane, and per-user queues (convertAndSendToUser) for targeted responses.

The meta-signal: you treat "real-time" as a spectrum of transports with different operational costs, choose the cheapest one that fits the data-flow direction, and can carry the choice all the way through to fan-out, backpressure, and deploy-time reconnect behavior.


12. Cheat-sheet#

Pick-the-transport, fast:

If you need…Use
Server→client updates, browserSSE (over HTTP/2)
Both sides streaming continuously/interactivelyWebSocket (STOMP if you want pub/sub + easy scaling)
Binary frames, low per-message overheadWebSocket
Resumable delivery with minimal effortSSE (Last-Event-ID)
Reactive bidirectional streaming with backpressure, Spring estateRSocket
Service-to-service streaming, typedgRPC streaming
Updates every 30–60s are fineJust poll

SSE wire fields: data: (payload) · event: (type) · id: (→ Last-Event-ID) · retry: (ms) · : (comment/heartbeat) · blank line ends an event.

WebSocket essentials: RFC 6455 · Upgrade: websocket101 · opcodes text/binary/close/ping/pong/continuation · client→server frames masked · close codes 1000/1001/1006/1011 · subprotocol via Sec-WebSocket-Protocol (STOMP, graphql-ws, MQTT).

Spring quick map: MVC push → SseEmitter · reactive push → Flux/Flow<ServerSentEvent<T>> · raw socket → WebSocketHandler + @EnableWebSocket · pub/sub → @EnableWebSocketMessageBroker + STOMP + SimpMessagingTemplate · scale-out → enableStompBrokerRelay or a Redis/Kafka backplane · blocking-at-scale → spring.threads.virtual.enabled=true.

Non-negotiables checklist: HTTP/2 for SSE · heartbeats under the LB idle timeout · bounded buffers + overflow policy · backplane for >1 node · wss:// + Origin allow-list · auth on the handshake · no long-lived secrets in URLs · jittered reconnect + drain on deploy · ConcurrentWebSocketSessionDecorator for shared sessions.


13. Self-test (answer out loud before an interview)#

  1. In one sentence each, when do you choose polling, SSE, and WebSocket — and what's the single deciding question?
  2. Why is SSE "still HTTP" and why does that matter operationally? Name three things it inherits for free.
  3. Walk the SSE wire format. What does id: do, and how does Last-Event-ID give you resumable delivery?
  4. What's the HTTP/1.1 six-connection gotcha for SSE, and exactly how does HTTP/2 fix it?
  5. Walk the WebSocket handshake. What does 101 Switching Protocols change on the wire? Why must the LB be Upgrade-aware?
  6. Why must client→server WebSocket frames be masked? What are ping/pong for?
  7. SseEmitter vs Flux<ServerSentEvent> — what's the difference in the threading/backpressure model, and when does each fall over?
  8. A single WebSocketSession written by two threads — what breaks and what's the fix? (Tie to thread safety.)
  9. Real-time works on one node, breaks on three. Diagnose and fix. Compare Redis vs Kafka vs a STOMP broker relay as the backplane.
  10. How do you keep one slow consumer from OOMing the node? Give the concrete mechanism for WebFlux SSE, blocking SSE, and raw WS.
  11. How do you authenticate a browser WebSocket given it can't set headers? Give three options and their trade-offs.
  12. What is CSWSH and how do you defend against it? How is it different from ordinary CSRF?
  13. Your service deploys 6×/day. What happens to every connection, and how do you make that a non-event?
  14. Default delivery guarantee for each transport, and how do you get to at-least-once? To effectively exactly-once?
  15. Where do RSocket, WebTransport, and gRPC streaming fit — and which is production-ready in a browser in 2026?

Lineage / sources: WebSocket = RFC 6455 (2011); WebSocket-over-HTTP/2 = RFC 8441; WebSocket-over-HTTP/3 = RFC 9220 (no production impls as of 2026). SSE = the WHATWG HTML EventSource standard. Spring Boot 4.0 GA Nov 20 2025 / Spring Framework 7.0 GA Nov 13 2025 — the SSE/WebSocket programming model is unchanged from Boot 3.2+, so all code here is version-stable. Virtual threads: JDK 21 GA; synchronized pinning removed by JEP 491 in JDK 24. WebTransport: reached Baseline in 2026 (Chromium; Firefox since v114/2023; Safari in 2026), but still newer/less proven than WebSocket. Verify version-specific facts against current docs before quoting them in an interview.