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?
- 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.
- 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. - Throw HTTP away after a handshake → WebSocket. Start as HTTP, send
Upgrade: websocket, get101 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.
| Approach | How it works | Server→client latency | Cost | When it's actually right |
|---|---|---|---|---|
| Regular polling | Client hits GET /updates every N seconds | Up to N seconds | Wasteful: most requests return "nothing new"; N× request overhead | Low-frequency data, N can be large (dashboards refreshing every 30–60s), simplicity wins |
| Long-polling | Client requests; server holds it open until data or timeout, then client re-requests | Near-instant | A held request per client (like SSE) but with re-request churn and header overhead each cycle | Fallback when SSE/WS can't traverse infrastructure; still the fallback SockJS uses |
| SSE | One long-lived HTTP response; server streams text events | Near-instant | One held connection per client; cheap on HTTP/2 | Server→client push. The default for real-time. |
| WebSocket | HTTP upgrade → raw full-duplex framed TCP | Near-instant both ways | One stateful connection per client + you own the protocol concerns | Bidirectional, interactive, high-frequency both ways. |
Decision ladder (say this out loud in an interview):
- Does the client need to send continuously/interactively, not just occasional requests? No → SSE. Yes → WebSocket.
- Is the update frequency low enough that a 15–60s delay is fine? Yes → just poll; don't hold a connection.
- Do you need to push binary (audio, video, protobuf frames) with minimal per-message overhead? → WebSocket (SSE is UTF-8 text only).
- 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. Multipledata: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 defaultonmessage/"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'sEventSourcehow 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#
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
EventSourceholds 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-advertisedSETTINGS_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 oneSharedWorker/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) — flipspring.threads.virtual.enabled=trueand "one thread per connection" stops being a scaling wall. - Spring WebFlux (reactive, non-blocking): return a
Flux<ServerSentEvent<T>>(Java) orFlow<ServerSentEvent<T>>(Kotlin coroutines). No thread per connection; backpressure is end-to-end through Reactive Streams.
3.1 Spring MVC — SseEmitter (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;
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
SseEmitter stream( String id,
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):
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)#
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
class SessionStreamController(private val sessions: SessionService) {
fun stream( 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#
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import reactor.core.publisher.Flux;
import java.time.Duration;
Flux<ServerSentEvent<SessionUpdate>> stream( 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).
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 answerpong. 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
closeframe carries a close code —1000normal,1001going away (server shutdown),1006abnormal (no close frame — the one you see on network drops),1011server error,1013try 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#
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;
class RawWsConfig implements WebSocketConfigurer {
private final SessionSocketHandler handler;
RawWsConfig(SessionSocketHandler handler) { this.handler = handler; }
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(handler, "/ws/sessions")
.setAllowedOrigins("https://app.example.com"); // NOT "*" — see §8 (CSWSH)
}
}
class SessionSocketHandler extends TextWebSocketHandler {
private final ConcurrentHashMap<String, WebSocketSession> live = new ConcurrentHashMap<>();
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);
}
protected void handleTextMessage(WebSocketSession session, TextMessage message)
throws Exception {
session.sendMessage(new TextMessage("ack:" + message.getPayload())); // client→server inbound
}
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
live.remove(session.getId());
}
}
Interview gold (ties to your concurrency/thread-safety guide): a single
WebSocketSessionmust not be written by two threads at once — concurrentsendMessagecalls 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 inConcurrentWebSocketSessionDecorator, 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#
import org.springframework.context.annotation.Configuration
import org.springframework.messaging.simp.config.MessageBrokerRegistry
import org.springframework.web.socket.config.annotation.*
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("...")
}
}
import org.springframework.messaging.handler.annotation.*
import org.springframework.messaging.simp.SimpMessagingTemplate
import org.springframework.stereotype.Controller
class SessionWsController(private val broker: SimpMessagingTemplate) {
// Client SENDs to /app/session/42/hello ; return value is published to /topic/session/42
fun hello( 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:
- Cookie-borne session on the handshake. The handshake is an HTTP request, so a
Secure; HttpOnly; SameSitesession cookie rides along and Spring Security authenticates it there. Cleanest for same-site browser apps. - 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. - STOMP
CONNECTframe header. Put the bearer token in a STOMP header on the CONNECT frame and authenticate in aChannelInterceptor— 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#
| Dimension | SSE | WebSocket |
|---|---|---|
| Direction | Server → client only | Full duplex |
| Underlying protocol | HTTP (stays HTTP the whole time) | HTTP handshake → raw TCP frames (RFC 6455) |
| Payload | UTF-8 text only (Base64 for binary) | Text or binary |
| Browser client | Native EventSource | Native WebSocket |
| Auto-reconnect | Yes, built in | No — you build it |
| Resume after drop | Yes — Last-Event-ID replay | No — you build it |
| Custom auth headers from browser | Limited (EventSource can't set headers either; cookies ok) | No (cookies / subprotocol / ticket) |
| Per-message overhead | Small text framing | Minimal (2–14 byte frame header) |
| Works with HTTP/2 multiplexing | Yes (dodges the 6-conn cap) | Yes, via RFC 8441 (widely supported ~2026) |
| Infra friendliness | High — it's just HTTP | Needs Upgrade-aware LB/proxy config |
| Horizontal scaling | Needs a backplane for cross-node fan-out | Needs a backplane; STOMP relay makes it turnkey |
| Spring API | SseEmitter (MVC) / Flux<ServerSentEvent> (WebFlux) | WebSocketHandler (raw) / @EnableWebSocketMessageBroker (STOMP) |
| Best fit | Notifications, live dashboards, progress, log/LLM token streaming, live session map | Chat, 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.
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 +
SseEmittersending 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 blockingSseEmittercode 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 asynchronized-pinning caveat that JEP 491 (JDK 24) removed — so the old "avoidsynchronized" 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 offset ⇒ at-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
Upgradepass-through (L7-aware or L4). Both benefit from — and reconnection-with-affinity may require — sticky sessions. - Idle timeouts: LBs/proxies kill idle connections (commonly 60s). Heartbeats (
:pingfor 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-IDresume 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://. Plaintextws://is trivially intercepted; browsers also block mixed content. - Validate the
Originheader on the WebSocket handshake — CSWSH. WebSocket is not subject to the same-origin policy the wayfetchis; a malicious page can open aWebSocketto 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 viaEventSourceis 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 (
@MessageMappingover 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
SseEmittercode 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
WebSocketSessionthread-safety trap (ties straight into a concurrency question). - Show delivery semantics: at-most-once by default; SSE's
Last-Event-IDresume 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 withLast-Event-IDand 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, browser | SSE (over HTTP/2) |
| Both sides streaming continuously/interactively | WebSocket (STOMP if you want pub/sub + easy scaling) |
| Binary frames, low per-message overhead | WebSocket |
| Resumable delivery with minimal effort | SSE (Last-Event-ID) |
| Reactive bidirectional streaming with backpressure, Spring estate | RSocket |
| Service-to-service streaming, typed | gRPC streaming |
| Updates every 30–60s are fine | Just 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: websocket → 101 · 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)#
- In one sentence each, when do you choose polling, SSE, and WebSocket — and what's the single deciding question?
- Why is SSE "still HTTP" and why does that matter operationally? Name three things it inherits for free.
- Walk the SSE wire format. What does
id:do, and how doesLast-Event-IDgive you resumable delivery? - What's the HTTP/1.1 six-connection gotcha for SSE, and exactly how does HTTP/2 fix it?
- Walk the WebSocket handshake. What does
101 Switching Protocolschange on the wire? Why must the LB beUpgrade-aware? - Why must client→server WebSocket frames be masked? What are ping/pong for?
SseEmittervsFlux<ServerSentEvent>— what's the difference in the threading/backpressure model, and when does each fall over?- A single
WebSocketSessionwritten by two threads — what breaks and what's the fix? (Tie to thread safety.) - Real-time works on one node, breaks on three. Diagnose and fix. Compare Redis vs Kafka vs a STOMP broker relay as the backplane.
- How do you keep one slow consumer from OOMing the node? Give the concrete mechanism for WebFlux SSE, blocking SSE, and raw WS.
- How do you authenticate a browser WebSocket given it can't set headers? Give three options and their trade-offs.
- What is CSWSH and how do you defend against it? How is it different from ordinary CSRF?
- Your service deploys 6×/day. What happens to every connection, and how do you make that a non-event?
- Default delivery guarantee for each transport, and how do you get to at-least-once? To effectively exactly-once?
- 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.