What is WebSocket — and When Should You Actually Use It?
WebSocket is a full-duplex, persistent communication protocol over a single TCP connection. Once established, both client and server can send messages at any time without the overhead of a new HTTP handshake per message. The connection starts as an HTTP request that is upgraded to the WebSocket protocol.
The key question is not "how do I use WebSocket" but "do I actually need it?" WebSocket introduces real operational complexity — stateful connections, horizontal scaling challenges, reconnection logic, load-balancer configuration — that HTTP polling or Server-Sent Events avoid entirely. Reach for WebSocket when the client also needs to send high-frequency data back to the server. If data only flows from server to client, SSE is simpler and correct.
/*
* HTTP polling — stateless, no special infrastructure, boring and correct.
* Right for: dashboards, order status, anything updated every few seconds.
*/
setInterval(() => {
fetch('/api/orders/status')
.then(r => r.json())
.then(updateUI);
}, 5000);
/*
* Server-Sent Events — server pushes, client reads. One direction only.
* Right for: live feeds, notifications, log streaming.
* Browser reconnects automatically — no manual reconnect logic needed.
*/
const sse = new EventSource('/api/events');
sse.onmessage = e => updateUI(JSON.parse(e.data));
/*
* WebSocket — full duplex. Right for: chat, collaborative editing,
* multiplayer games, trading where the CLIENT sends frequent data too.
* Wrong for: anything where only the server sends data.
*/
const ws = new WebSocket('wss://api.example.com/ws');
ws.onmessage = e => handleMessage(JSON.parse(e.data));
ws.send(JSON.stringify({ type: 'ORDER_UPDATE', orderId: 9001 }));
| Mechanism | Direction | Connection | Auto-reconnect | Best for |
|---|---|---|---|---|
| HTTP Polling | Client pulls | New per poll | N/A | Infrequent updates, simple infrastructure |
| Server-Sent Events | Server → Client only | Persistent HTTP stream | Built into browser | Live feeds, notifications, one-way push |
| WebSocket | Full-duplex | Persistent (upgraded) | Manual implementation | Chat, games, collaborative tools, high-frequency bidirectional |
The WebSocket Handshake
A WebSocket connection begins as a normal HTTP/1.1 GET request —
this is why it uses port 80/443 and traverses the same firewalls
and proxies as HTTP. The client sends an Upgrade
header; if the server agrees, the connection is promoted and HTTP
is no longer spoken on that socket.
// 1. Client sends HTTP upgrade request
GET /ws/chat HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com
// 2. Server agrees — connection is upgraded
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
// 3. From here, both sides speak WebSocket frames.
// Either side can send at any time until one side closes.
The HTTP upgrade request is the only moment where standard HTTP authentication mechanisms (cookies, JWT Bearer headers) are available. After the protocol switches, there are no more HTTP headers per message. This means: validate the user's token or session cookie during the handshake, in an HTTP interceptor or handshake configurator. If you defer authentication to the first WebSocket message — or worse, trust a username from the URL query string — any client can impersonate any user by connecting with a different query parameter.
Jakarta WebSocket API — The Correct Pattern
The Jakarta WebSocket API (JSR 356) works in any Jakarta EE container (WildFly, Payara, Tomcat with Tyrus). Four annotations cover the full lifecycle. The examples below correct the two most widespread production bugs in WebSocket tutorials: the static session store classloader leak, and blocking synchronous sends in a broadcast loop.
Bug 1 — Static session store causes classloader leak on hot-redeploy
// WRONG — static field on the endpoint class
@ServerEndpoint("/ws/chat")
public class ChatEndpoint {
// In a Jakarta EE server, static fields live on the class object
// in the application classloader. On hot-redeploy, a new classloader
// is created but the old one cannot be GC'd because this Set still
// holds Session references that reference the old classloader.
// After N deploys there are N leaked classloaders → OutOfMemoryError: Metaspace.
private static Set<Session> sessions =
Collections.newSetFromMap(new ConcurrentHashMap<>()); // ✗
}
// CORRECT — session store in a CDI @ApplicationScoped singleton
@ApplicationScoped
public class SessionRegistry {
private final Set<Session> sessions =
Collections.newSetFromMap(new ConcurrentHashMap<>());
public void register(Session s) { sessions.add(s); }
public void deregister(Session s) { sessions.remove(s); }
public void broadcast(String json) {
sessions.stream()
.filter(Session::isOpen)
.forEach(s ->
// Bug 2: getBasicRemote() is SYNCHRONOUS — it blocks until the
// message is fully sent. Calling it 500 times in a loop serializes
// all sends through the @OnMessage thread, starving the server
// of worker threads under load. Use getAsyncRemote() instead.
s.getAsyncRemote().sendText(json, result -> {
if (!result.isOK()) {
log.warn("Send failed to {}: {}",
s.getId(), result.getException().getMessage());
}
})
);
}
}
Full endpoint — CDI injected, async broadcast, correct error handling
// Configurator that routes CDI injection through the container
public class CdiAwareConfigurator extends ServerEndpointConfig.Configurator {
@Override
public <T> T getEndpointInstance(Class<T> endpointClass) throws InstantiationException {
return CDI.current().select(endpointClass).get();
}
}
@ServerEndpoint(value = "/ws/chat", configurator = CdiAwareConfigurator.class)
public class ChatEndpoint {
@Inject
private SessionRegistry registry;
private static final Jsonb JSONB = JsonbBuilder.create();
@OnOpen
public void onOpen(Session session) {
session.setMaxIdleTimeout(10 * 60 * 1000); // 10 min inactivity
session.setMaxTextMessageBufferSize(64 * 1024); // 64 KB max frame
registry.register(session);
registry.broadcast(JSONB.toJson(ChatMessage.system("A user joined")));
}
@OnMessage
public void onMessage(String json, Session session) {
ChatMessage msg = JSONB.fromJson(json, ChatMessage.class);
// validate, persist if needed, then broadcast
registry.broadcast(JSONB.toJson(msg));
}
@OnClose
public void onClose(Session session, CloseReason reason) {
registry.deregister(session);
}
@OnError
public void onError(Session session, Throwable t) {
log.error("Error on session {}", session.getId(), t);
registry.deregister(session); // clean up on error too
}
}
// Records for message DTOs — never mutable classes with getters/setters
public record ChatMessage(String sender, String content, long timestamp) {
public ChatMessage {
if (sender == null || sender.isBlank()) throw new IllegalArgumentException("sender required");
if (content == null || content.isBlank()) throw new IllegalArgumentException("content required");
}
public static ChatMessage system(String content) {
return new ChatMessage("system", content, System.currentTimeMillis());
}
}
Spring WebSocket with STOMP — What Production Spring Apps Actually Use
Most Spring Boot applications don't use the raw Jakarta WebSocket API. They use Spring's WebSocket support with STOMP (Simple Text Oriented Messaging Protocol) — a messaging layer on top of WebSocket that adds destinations, subscriptions, and message routing. Spring Security integrates natively with the STOMP handshake; user-specific queues come for free.
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// Messages to /topic or /queue are handled by the broker
registry.enableSimpleBroker("/topic", "/queue");
// Messages to /app are routed to @MessageMapping methods
registry.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("https://*.example.com")
.withSockJS(); // falls back to long-polling if WebSocket is blocked by a corporate proxy
}
}
@Controller
public class ChatController {
private final SimpMessagingTemplate messagingTemplate;
public ChatController(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
// Client sends to /app/chat → method processes → broadcasts to /topic/chat
@MessageMapping("/chat")
@SendTo("/topic/chat")
public ChatMessage handleMessage(ChatMessage message, Principal user) {
// Principal is the Spring Security authenticated user from the handshake
return new ChatMessage(user.getName(), message.content(), System.currentTimeMillis());
}
// Push a notification to one specific user's private queue
public void notifyUser(String username, OrderEvent event) {
messagingTemplate.convertAndSendToUser(
username,
"/queue/notifications", // resolves to /user/{username}/queue/notifications
event
);
}
}
// JavaScript client — STOMP.js (npm: @stomp/stompjs)
import { Client } from '@stomp/stompjs';
const client = new Client({
brokerURL: 'wss://api.example.com/ws',
connectHeaders: { Authorization: `Bearer ${getAccessToken()}` },
reconnectDelay: 5000 // STOMP.js handles reconnect automatically
});
client.onConnect = () => {
client.subscribe('/topic/chat', frame => {
const msg = JSON.parse(frame.body);
displayMessage(msg.sender, msg.content);
});
client.subscribe('/user/queue/notifications', frame => {
showNotification(JSON.parse(frame.body));
});
};
client.activate();
Authentication — the Correct Pattern
Wrong: trusting identity from a query parameter
// Any client can connect as any user by changing the URL
new WebSocket("wss://api.example.com/ws?username=alice");
@OnOpen
public void onOpen(Session session) {
String username = session.getRequestParameterMap().get("username").get(0);
userSessions.put(username, session); // ✗ completely unauthenticated
}
Right: authenticate in the handshake configurator (Jakarta)
public class AuthHandshakeConfigurator extends ServerEndpointConfig.Configurator {
@Override
public void modifyHandshake(ServerEndpointConfig sec,
HandshakeRequest request,
HandshakeResponse response) {
List<String> authHeaders = request.getHeaders()
.getOrDefault("Authorization", List.of());
if (authHeaders.isEmpty() || !authHeaders.get(0).startsWith("Bearer ")) {
throw new SecurityException("Bearer token required");
// Container closes the handshake with a non-101 response
}
String token = authHeaders.get(0).substring(7);
Principal principal = jwtService.validate(token); // throws on invalid token
sec.getUserProperties().put("principal", principal);
}
}
@OnOpen
public void onOpen(Session session, EndpointConfig config) {
Principal user = (Principal) config.getUserProperties().get("principal");
session.getUserProperties().put("principal", user);
}
Right: Spring Security on the handshake endpoint (Spring Boot)
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/ws/**").authenticated() // handshake requires auth
.anyRequest().authenticated()
);
// Your existing JWT filter validates the Bearer token on the HTTP upgrade request.
// The authenticated Principal is then available in all @MessageMapping methods.
return http.build();
}
}
Heartbeat, Dead Connection Detection, and Client Reconnection
TCP connections can go silently dead — a mobile OS suspends the app, a NAT gateway cleans up an idle mapping, a network switch reboots. The server has no FIN packet to act on. Without a heartbeat it holds dead Session objects in the registry indefinitely.
Server-side: idle timeout and protocol-level ping
@OnOpen
public void onOpen(Session session) {
// If no message or pong arrives within this window, the container closes
// the session and fires @OnClose automatically.
session.setMaxIdleTimeout(60_000); // 60 seconds
}
// Send a WebSocket protocol-level ping every 30 seconds from a scheduler.
// The browser responds with a pong frame automatically at the protocol level —
// you don't need application-level pong handling.
// Do NOT send a text frame with content "ping" — that's an application message,
// not a protocol ping, and won't reset the idle timeout on the server side.
executor.scheduleAtFixedRate(() -> {
if (session.isOpen()) {
try {
session.getBasicRemote().sendPing(ByteBuffer.allocate(0));
} catch (IOException e) {
try { session.close(); } catch (IOException ignored) {}
registry.deregister(session);
}
}
}, 30, 30, TimeUnit.SECONDS);
Client-side: reconnection with exponential backoff
// Production WebSocket client — not just new WebSocket() and hope for the best
class ReconnectingWebSocket {
constructor(url, options = {}) {
this.url = url;
this.maxDelay = options.maxDelay ?? 30_000;
this.onMessage = options.onMessage ?? (() => {});
this.attempt = 0;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.attempt = 0; // reset backoff counter on success
};
this.ws.onmessage = event => this.onMessage(JSON.parse(event.data));
this.ws.onclose = event => {
if (event.code === 1000) return; // 1000 = normal closure — don't reconnect
const delay = Math.min(1000 * Math.pow(2, this.attempt++), this.maxDelay);
setTimeout(() => this.connect(), delay);
// Delays: 1s, 2s, 4s, 8s, 16s, 30s (capped), 30s, 30s...
};
this.ws.onerror = err => console.error('WebSocket error', err);
}
send(data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
// Messages during reconnect are dropped — consider queuing them
// if your use case cannot tolerate message loss during reconnection.
}
}
Scaling WebSockets — The Problem HTTP Doesn't Have
HTTP requests are stateless — any instance can serve any request. WebSocket connections are stateful — a connection is pinned to the instance that accepted the handshake. Horizontal scaling requires deliberate architectural choices.
// Problem: broadcast on Instance A can't reach clients on Instance B
Instance A Instance B
Alice (connected) Bob (connected)
Alice sends message →
Instance A broadcasts →
Alice receives it Bob never sees it
// Fix: all instances subscribe to a shared message broker
Instance A Instance B
Alice ──┐ ┌── Bob
▼ ▼
┌─────────────────────────┐
│ Redis Pub/Sub │ ← or RabbitMQ, Kafka
└─────────────────────────┘
Alice sends → Instance A publishes to Redis →
Redis delivers to all subscribers →
Instance B pushes to Bob ✓
// Spring Boot: replace in-memory broker with a full STOMP broker relay
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// RabbitMQ with STOMP plugin — port 61613
registry.enableStompBrokerRelay("/topic", "/queue")
.setRelayHost("rabbitmq.internal")
.setRelayPort(61613)
.setClientLogin("ws_client")
.setClientPasscode("${ws.broker.password}");
registry.setApplicationDestinationPrefixes("/app");
}
The HTTP upgrade request goes through the load balancer normally.
After the upgrade, the TCP connection is persistent — the
load balancer must not terminate it. AWS ALB supports WebSocket
natively with no configuration. Nginx requires three additional
directives in the location block:
proxy_http_version 1.1;,
proxy_set_header Upgrade $http_upgrade;, and
proxy_set_header Connection "upgrade";. Without them,
Nginx closes the connection after the upgrade response, the client
enters the reconnect loop immediately, and nobody ever stays
connected for more than a second.
Best Practices and Common Pitfalls
✅ Do
- Authenticate at the HTTP handshake — the upgrade request is the only moment where HTTP cookies and Authorization headers are available
- Store sessions in a CDI
@ApplicationScopedsingleton, never in astaticfield on the endpoint class — static fields cause classloader leaks on hot-redeploy - Use
getAsyncRemote().sendText()for broadcasts —getBasicRemote()blocks the calling thread for each send, serializing all sends through the@OnMessagethread - Set idle timeouts and message size limits in
@OnOpen— without them a client can hold a connection open indefinitely or send arbitrarily large frames - Implement exponential backoff reconnection in the client — reconnecting immediately on disconnect hammers the server during restarts or outages
- Use a shared message broker (Redis, RabbitMQ) when running more than one instance — in-memory session stores only work for single-instance deployments
❌ Don't
- Don't trust identity from a URL query parameter — it's trivially spoofable by any client and bypasses all authentication
- Don't use
getBasicRemote().sendText()in a broadcast loop — it serializes all sends, stalls under load, and is the most common WebSocket performance bug - Don't use WebSocket when SSE or polling would suffice — the operational complexity of stateful connections is only worth it when the client genuinely needs to send high-frequency data too
- Don't assume your reverse proxy supports WebSocket without checking — Nginx requires explicit proxy headers; forgetting them causes immediate disconnect on every connection
- Don't send a text frame with content
"ping"as a heartbeat — that's an application message; usesession.getBasicRemote().sendPing()to send a protocol-level ping frame
Interview Questions
Q: What is the difference between WebSocket and HTTP?
HTTP is request/response — the client sends a request, the server
responds, and the connection is released. WebSocket starts as an
HTTP upgrade request, then switches to a full-duplex persistent
connection where both sides can send messages at any time without
a new handshake per message.
Q: When would you choose Server-Sent Events over WebSocket?
When data only flows from server to client — notifications, live
feeds, order status updates. SSE is simpler (plain HTTP), has
automatic browser reconnect built in, and doesn't require special
load balancer configuration. WebSocket is the right choice only
when the client also needs to send high-frequency data back to
the server.
Q: What is the WebSocket handshake?
An HTTP/1.1 GET request with Upgrade: websocket and
Connection: Upgrade. If the server agrees, it responds
with 101 Switching Protocols and the TCP connection is
promoted — no more HTTP is spoken on that socket.
Q: A Jakarta WebSocket endpoint uses private static Set<Session> to track connected clients. The application hot-deploys nightly. What breaks over time, and why?
A static field lives on the class object in the
application classloader. On hot-redeploy, the server creates a new
classloader and loads a fresh version of every class. The old
classloader should be garbage-collected, but the static
Set still holds references to Session objects. Those
Sessions reference internal server objects that reference the old
classloader, creating a retain cycle. The old classloader cannot be
collected. After N nightly deploys there are N abandoned classloaders
in the JVM. Eventually: OutOfMemoryError: Metaspace. The
fix: move the session store to a CDI @ApplicationScoped
bean whose lifecycle the container manages correctly across
redeployments.
Q: A broadcast method calls session.getBasicRemote().sendText() in a loop over 500 sessions. Under load, client latency spikes and threads time out. What is the mechanism, and what is the fix?
getBasicRemote() is the synchronous blocking API — it
does not return until the message has been fully written to the
socket buffer. Calling it 500 times in a loop in the
@OnMessage handler blocks that thread for the duration
of 500 sequential network writes. Any message arriving while this is
in progress waits for a thread from the server's worker pool. Under
load, the pool saturates, queues grow, and latency spikes. The fix
is session.getAsyncRemote().sendText(message, callback)
— it returns immediately and writes asynchronously. The callback
receives success or failure so you can log without blocking.
Q: You deploy a Spring Boot WebSocket app to three instances behind a load balancer. Users on different instances cannot see each other's messages. What is the architectural problem and what are the solutions?
WebSocket connections are stateful and pinned to the instance that
accepted the handshake. A broadcast from Alice on Instance A only
reaches sessions tracked by Instance A — Instance B never sees it.
Option 1 (correct): replace Spring's in-memory broker with a STOMP
relay to RabbitMQ or Redis Pub/Sub. All instances subscribe to the
same broker; every instance sees every message regardless of who's
connected where. Option 2 (wrong in most cases): sticky sessions on
the load balancer, routing all connections from the same user to the
same instance. This "solves" the visibility problem but eliminates
the resilience benefit of multiple instances and breaks completely
when an instance restarts. Option 1 is the production answer.