Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

WebSocket & Real-Time Communication

Real-time push in Spring: STOMP over WebSocket, broadcasting and user-targeted messages, authenticating the handshake, scaling with an external broker, and when SSE is the better fit.

Advanced6 min readUpdated
On this page

HTTP request/response cannot express "tell me when something changes". WebSocket and SSE both can, with very different costs. Choosing correctly saves a great deal of operational trouble.

Key Takeaways

  • SSE for one-way push, WebSocket for genuine two-way messaging. Most "we need WebSocket" requirements are actually SSE.
  • STOMP gives WebSocket the subscription semantics a raw socket lacks.
  • Authenticate at the handshake or the CONNECT frame — a WebSocket has no per-message auth.
  • The in-memory broker does not scale past one instance; use a broker relay.
  • Configure heartbeats below the proxy idle timeout.

Choosing between them

Most real-time requirements are satisfied by SSE or even polling. Reach for WebSocket when the client genuinely needs to send too.

The honest starting point is polling. If an update every thirty seconds is acceptable, a conditional GET with an ETag costs almost nothing when nothing changed, works through every proxy ever built, and has no connection state to manage. Move up only when the latency requirement genuinely demands it.

Server-Sent Events

ProgressController.java
@GetMapping(value = "/api/v1/jobs/{id}/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter events(@PathVariable String id) {
 
    SseEmitter emitter = new SseEmitter(Duration.ofMinutes(30).toMillis());
 
    // Always register all three, or emitters leak when clients vanish.
    emitter.onCompletion(() -> registry.remove(id, emitter));
    emitter.onTimeout(() -> { registry.remove(id, emitter); emitter.complete(); });
    emitter.onError(ex -> registry.remove(id, emitter));
 
    registry.add(id, emitter);
 
    // The id field lets a reconnecting client resume via Last-Event-ID.
    try {
        emitter.send(SseEmitter.event().name("connected").id("0").data(Map.of("jobId", id)));
    } catch (IOException ex) {
        emitter.completeWithError(ex);
    }
    return emitter;
}

The browser's EventSource reconnects on its own and sends Last-Event-ID so you can resume from where it stopped. That is a substantial amount of reliability logic you would otherwise write yourself for WebSocket.

STOMP over WebSocket

WebSocketConfig.java
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
 
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // Single instance: in-memory. Multiple instances: relay (see below).
        registry.enableSimpleBroker("/topic", "/queue")
                .setHeartbeatValue(new long[] { 10_000, 10_000 })
                .setTaskScheduler(heartbeatScheduler());
 
        registry.setApplicationDestinationPrefixes("/app");
        registry.setUserDestinationPrefix("/user");
    }
 
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                .setAllowedOriginPatterns("https://app.acme.com")
                .withSockJS();      // fallback for networks that block WebSocket
    }
}
ChatController.java
@Controller
public class ChatController {
 
    private final SimpMessagingTemplate messaging;
 
    @MessageMapping("/rooms/{roomId}/send")
    @SendTo("/topic/rooms/{roomId}")
    public ChatMessage send(@DestinationVariable String roomId,
                            @Payload @Valid ChatMessage message,
                            Principal principal) {
        // Take the sender from the authenticated principal, never from the
        // payload — a client can put any name it likes in the body.
        return message.withSender(principal.getName()).withTimestamp(Instant.now());
    }
 
    public void notifyUser(String username, Notification notification) {
        // Routes to /user/queue/notifications for this session only.
        messaging.convertAndSendToUser(username, "/queue/notifications", notification);
    }
}

Authenticating the connection

A WebSocket authenticates once, at connection time. There is no per-message credential, so the handshake or the CONNECT frame is your only opportunity:

StompAuthInterceptor.java
@Component
public class StompAuthInterceptor implements ChannelInterceptor {
 
    private final JwtDecoder jwtDecoder;
 
    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor =
                MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
 
        if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
            String token = accessor.getFirstNativeHeader("Authorization");
            if (token == null || !token.startsWith("Bearer ")) {
                throw new MessagingException("missing credentials");
            }
            Jwt jwt = jwtDecoder.decode(token.substring(7));
            accessor.setUser(new JwtAuthenticationToken(jwt));
        }
 
        // Subscriptions must be authorised too: without this check any
        // authenticated user can subscribe to any room's topic.
        if (accessor != null && StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {
            authorizeSubscription(accessor.getUser(), accessor.getDestination());
        }
        return message;
    }
}

The SUBSCRIBE check is the one that gets forgotten. Authenticating the connection proves who the user is; it says nothing about which topics they may read. Without an authorisation check on subscribe, any logged-in user can subscribe to /topic/rooms/anything and read other people's messages.

Note also that browsers do not send a bearer token on the WebSocket handshake, and the origin policy for WebSocket is weaker than CORS — always set setAllowedOriginPatterns explicitly, never to *.

Scaling beyond one instance

The simple broker keeps subscriptions in the memory of the instance that owns the connection. With two instances behind a load balancer, a message published on instance A never reaches a subscriber connected to instance B. The fix is to move fan-out into a real broker:

BrokerRelay.java
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
    registry.enableStompBrokerRelay("/topic", "/queue")
            .setRelayHost("rabbitmq.internal")
            .setRelayPort(61613)
            .setClientLogin("app").setClientPasscode(passcode)
            .setSystemLogin("app").setSystemPasscode(passcode)
            .setVirtualHost("/chat");
}

RabbitMQ with the STOMP plugin becomes the shared subscription registry, and any instance can publish to any subscriber. This also survives an instance restart, which the in-memory broker does not.

Operational details that bite

Heartbeats must be shorter than the idle timeout of every proxy on the path. A load balancer with a 60-second idle timeout will silently close a connection whose heartbeat is 120 seconds, and clients will see mysterious disconnections that never reproduce locally.

Connection count is a resource. Each WebSocket holds a socket, a session and its subscription state for as long as the client is connected — potentially hours. Ten thousand concurrent connections is a very different capacity planning exercise from ten thousand requests per second, and it is the number to monitor and alert on.

Backpressure matters when you publish faster than a client consumes. Configure send buffer and timeout limits so a slow client is disconnected rather than accumulating an unbounded queue in your heap.

Reconnection is the client's job, but the server has to make it possible. Design messages so a client that missed some can resynchronise — either by fetching current state over REST on reconnect, or by sequencing messages so gaps are detectable.

Designing the message contract

A WebSocket channel is an API, and it deserves the same care as an HTTP one — but teams routinely treat it as a private channel between their own front end and back end, and then discover the same versioning problems eighteen months later.

Give every message an explicit type field and treat unknown types as ignorable rather than fatal. This single decision is what lets you add message types without coordinating a simultaneous client and server deploy, which is otherwise impossible when clients are long-lived browser sessions that may be running last week's JavaScript.

Include a sequence number or timestamp per channel. A client that reconnects needs to know whether it missed anything, and a gap in a sequence is the only cheap way to detect that. Without it, the only safe reconnection strategy is to discard local state and refetch everything.

Keep messages small and self-contained. A push that says "order 4711 changed" and lets the client fetch the detail over REST is often better than pushing the full object: it keeps the socket cheap, avoids duplicating your serialisation logic across two transports, and means the authorisation check happens on the REST call where it already exists.

Finally, decide explicitly what happens when a client is not connected. Real-time delivery is best-effort by nature; if a notification matters, it needs a durable store the client can read on reconnect, and the socket becomes an optimisation rather than the delivery mechanism.

What to take away

Start with polling, move to SSE when you need push, and reach for WebSocket only when the client genuinely needs to send. Authorise the subscription, not just the connection. Tune heartbeats below your proxy timeouts, and swap in a broker relay the moment you run more than one instance.

Frequently Asked Questions

WebSocket or Server-Sent Events?
SSE if the data only flows server to client — notifications, live prices, progress updates. It is plain HTTP, reconnects automatically, works through proxies and needs no extra protocol. WebSocket when you need genuine bidirectional messaging, such as chat or collaborative editing.
Why does my WebSocket work locally and fail behind the load balancer?
Two usual causes. The proxy is not configured to pass the Upgrade and Connection headers, so the handshake never completes. Or its idle timeout is shorter than your heartbeat interval and it closes the connection as idle. Configure heartbeats below the proxy timeout and enable WebSocket support explicitly.
How do I broadcast across multiple application instances?
The simple in-memory broker only knows about sessions on its own instance, so a message sent from instance A never reaches a subscriber on instance B. Switch to a STOMP broker relay backed by RabbitMQ or ActiveMQ, which becomes the shared fan-out point.

Related tutorials