WebSockets

Full-duplex persistent connections — when to use them, how the handshake works, and the production bugs that make broadcast implementations fail under load

← Back to Index

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 }));
MechanismDirectionConnectionAuto-reconnectBest for
HTTP PollingClient pullsNew per pollN/AInfrequent updates, simple infrastructure
Server-Sent EventsServer → Client onlyPersistent HTTP streamBuilt into browserLive feeds, notifications, one-way push
WebSocketFull-duplexPersistent (upgraded)Manual implementationChat, 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.
Authentication happens during the handshake — not after

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");
}
Load balancer configuration for WebSocket

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 @ApplicationScoped singleton, never in a static field 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 @OnMessage thread
  • 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; use session.getBasicRemote().sendPing() to send a protocol-level ping frame

Interview Questions

🎓 Junior level

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.

🔥 Senior level

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.