WebSocket in Spring Boot: Real-Time Chat, Notifications & Horizontal Scaling (2026)
Build production-grade real-time applications with Spring Boot and WebSocket: STOMP protocol, SockJS fallback, JWT authentication at the STOMP layer, user-specific queues, Redis pub/sub for multi-instance scaling, presence indicators, and production tuning.
TL;DR: WebSocket is the right choice for persistent bidirectional communication — chat, live dashboards, collaborative editing. Use STOMP over WebSocket for pub/sub topic routing in Spring Boot, and Redis pub/sub to broadcast messages across multiple server instances.
1. WebSocket vs SSE vs Long Polling
| Technique | Direction | Protocol | Use Case | Server Load |
|---|---|---|---|---|
| Long Polling | Server → Client | HTTP | Simple notifications | High (N open connections) |
| SSE | Server → Client only | HTTP/2 | Live feeds, dashboards | Low |
| WebSocket | ⚡ Bidirectional | WS (upgrade from HTTP) | Chat, collab, gaming | Low (persistent conn) |
2. Spring Boot Setup: STOMP Config
// ❌ BAD: Simple in-memory broker (no clustering, lost messages on restart)
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic", "/queue"); // In-memory only!
registry.setApplicationDestinationPrefixes("/app");
}
// ✅ GOOD: Full STOMP config with SockJS + external broker relay
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// External broker relay (RabbitMQ with STOMP plugin) — survives restarts, scales
registry.enableStompBrokerRelay("/topic", "/queue")
.setRelayHost("rabbitmq.internal")
.setRelayPort(61613)
.setClientLogin("ws-user")
.setClientPasscode("ws-password")
.setSystemLogin("system")
.setSystemPasscode("system-password")
.setVirtualHost("/")
.setHeartbeatSendInterval(10000)
.setHeartbeatReceiveInterval(10000);
registry.setApplicationDestinationPrefixes("/app");
registry.setUserDestinationPrefix("/user");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("https://*.myapp.com")
.withSockJS() // fallback for browsers that don't support WebSocket
.setHeartbeatTime(25_000);
}
}
3. STOMP Protocol: Message Flow
- SUBSCRIBE /topic/chat-room-1 → receive all messages published to that topic
- SEND /app/chat.send → routed to @MessageMapping("/chat.send") handler
- @SendTo("/topic/chat-room-1") → broadcast to all subscribers of that topic
- @SendToUser("/queue/reply") → sends only to the authenticated user who sent the message
SimpMessagingTemplate→ programmatically send to any destination from any Spring component
4. Real-Time Chat Application
// ✅ GOOD: ChatController + WebSocket event listener
@Controller
public class ChatController {
@Autowired private SimpMessagingTemplate messagingTemplate;
@Autowired private MessageRepository messageRepository;
@MessageMapping("/chat.send")
@SendTo("/topic/chat-room-{roomId}") // broadcast to room
public ChatMessage sendMessage(@DestinationVariable String roomId,
@Payload ChatMessage message,
Principal principal) {
message.setSenderId(principal.getName());
message.setTimestamp(Instant.now());
// Async persist to DB (don't block WebSocket thread)
CompletableFuture.runAsync(() -> messageRepository.save(message));
return message;
}
@MessageMapping("/chat.typing")
public void typingIndicator(@DestinationVariable String roomId,
@Payload TypingEvent event, Principal principal) {
event.setUserId(principal.getName());
messagingTemplate.convertAndSend("/topic/chat-room-" + roomId + "/typing", event);
}
}
@Component
public class WebSocketEventListener {
@Autowired private SimpMessageSendingOperations messagingTemplate;
@EventListener
public void handleWebSocketConnectListener(SessionConnectedEvent event) {
StompHeaderAccessor sha = StompHeaderAccessor.wrap(event.getMessage());
log.info("User connected: {}", sha.getUser().getName());
}
@EventListener
public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
StompHeaderAccessor sha = StompHeaderAccessor.wrap(event.getMessage());
ChatMessage leaveMessage = ChatMessage.builder()
.type(MessageType.LEAVE)
.senderId(sha.getUser().getName())
.build();
messagingTemplate.convertAndSend("/topic/public", leaveMessage);
}
}
5. JWT Authentication for WebSocket
// ✅ GOOD: ChannelInterceptor validates JWT on STOMP CONNECT frame
@Component
public class JwtChannelInterceptor implements ChannelInterceptor {
@Autowired private JwtDecoder jwtDecoder;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(
message, StompHeaderAccessor.class);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
// JWT passed in STOMP CONNECT header (not HTTP Authorization header)
String token = accessor.getFirstNativeHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
try {
Jwt jwt = jwtDecoder.decode(token.substring(7));
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
jwt.getSubject(), null, extractAuthorities(jwt));
accessor.setUser(auth); // set Principal for @SendToUser
} catch (JwtException e) {
throw new MessagingException("Invalid JWT: " + e.getMessage());
}
} else {
throw new MessagingException("Missing JWT in CONNECT frame");
}
}
return message;
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new JwtChannelInterceptor());
}
}
6. User-Specific Queues: Private Messages
// ✅ GOOD: Direct message to a specific user using /user/ prefix
// Client subscribes to: /user/queue/direct-messages
// (STOMP prefix /user + username + /queue/direct-messages)
@MessageMapping("/dm.send")
public void sendDirectMessage(@Payload DirectMessage dm, Principal sender) {
dm.setSenderId(sender.getName());
dm.setTimestamp(Instant.now());
// Send to recipient only (even if connected to different server instance)
messagingTemplate.convertAndSendToUser(
dm.getRecipientId(), // username
"/queue/direct-messages", // destination
dm // payload
);
// Also send back to sender (for their own chat window)
messagingTemplate.convertAndSendToUser(
sender.getName(),
"/queue/direct-messages",
dm
);
// Async persist
messageRepository.save(dm);
}
7. Horizontal Scaling with Redis Pub/Sub
The problem: User A connects to Server 1. User B connects to Server 2. If User A sends a message, Server 1 can only deliver it to clients connected to Server 1 — User B never receives it.
Solution: All servers subscribe to a Redis pub/sub channel. When any server receives a message, it publishes to Redis; all servers receive it and forward to their locally connected clients.
// ✅ GOOD: Redis pub/sub relay for WebSocket horizontal scaling
@Configuration
public class RedisWebSocketRelay {
@Autowired private SimpMessagingTemplate messagingTemplate;
@Bean
public MessageListenerAdapter messageListenerAdapter() {
return new MessageListenerAdapter(new RedisMessageDelegate(messagingTemplate));
}
@Bean
public RedisMessageListenerContainer redisListenerContainer(
RedisConnectionFactory connectionFactory,
MessageListenerAdapter listenerAdapter) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
// Subscribe to all chat channel patterns
container.addMessageListener(listenerAdapter, new PatternTopic("chat:*"));
return container;
}
}
@Service
public class ChatBroadcastService {
@Autowired private RedisTemplate<String, ChatMessage> redisTemplate;
@Autowired private SimpMessagingTemplate wsTemplate;
public void broadcast(String roomId, ChatMessage message) {
// Publish to Redis — all instances will receive and forward to WS clients
redisTemplate.convertAndSend("chat:" + roomId, message);
}
}
class RedisMessageDelegate {
private final SimpMessagingTemplate wsTemplate;
public void handleMessage(ChatMessage message, String channel) {
String roomId = channel.replace("chat:", "");
wsTemplate.convertAndSend("/topic/chat-room-" + roomId, message);
}
}
8. Presence & Typing Indicators
// ✅ GOOD: Presence tracking with Redis + TTL for crash recovery
@Service
public class PresenceService {
@Autowired private RedisTemplate<String, String> redisTemplate;
private static final int PRESENCE_TTL_SECONDS = 35; // slightly > heartbeat interval
public void userConnected(String roomId, String userId) {
String key = "presence:" + roomId;
redisTemplate.opsForSet().add(key, userId);
redisTemplate.expire(key, PRESENCE_TTL_SECONDS, TimeUnit.SECONDS); // auto-cleanup on crash
broadcastPresence(roomId);
}
public void userDisconnected(String roomId, String userId) {
redisTemplate.opsForSet().remove("presence:" + roomId, userId);
broadcastPresence(roomId);
}
// Called on each heartbeat to refresh TTL
public void refreshPresence(String roomId, String userId) {
redisTemplate.opsForSet().add("presence:" + roomId, userId);
redisTemplate.expire("presence:" + roomId, PRESENCE_TTL_SECONDS, TimeUnit.SECONDS);
}
public Set<String> getOnlineUsers(String roomId) {
return redisTemplate.opsForSet().members("presence:" + roomId);
}
}
9. Production Tuning & Connection Limits
| Config | Default | Production | Notes |
|---|---|---|---|
| Max text message size | 64KB | 128KB | For rich messages with metadata |
| Max binary message size | 512KB | 1MB | Adjust per use case |
| Connections per JVM | ~65K theoretical | 20-30K safe | ~10KB memory per idle conn |
| SockJS heartbeat | 25s | 25s | Keep alive through proxies/LBs |
10. Interview Questions & Production Checklist
✅ WebSocket Production Checklist
- Use external broker relay for production clustering
- JWT auth on STOMP CONNECT frame
- Redis pub/sub for multi-instance message delivery
- Presence tracking with Redis TTL
- Client reconnect with exponential backoff
- SockJS fallback for load balancer compatibility
- Heartbeat to keep connections alive through proxies
- Graceful shutdown drain before pod restart
Tags:
websocket spring boot
spring boot stomp
real time chat spring boot 2026
websocket horizontal scaling redis
websocket jwt auth
sockjs spring boot
Leave a Comment
Related Posts
Back to Blog
Last updated: April 11, 2026