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.

WebSocket Spring Boot Real-Time Guide 2026
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

TechniqueDirectionProtocolUse CaseServer Load
Long PollingServer → ClientHTTPSimple notificationsHigh (N open connections)
SSEServer → Client onlyHTTP/2Live feeds, dashboardsLow
WebSocket⚡ BidirectionalWS (upgrade from HTTP)Chat, collab, gamingLow (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

ConfigDefaultProductionNotes
Max text message size64KB128KBFor rich messages with metadata
Max binary message size512KB1MBAdjust per use case
Connections per JVM~65K theoretical20-30K safe~10KB memory per idle conn
SockJS heartbeat25s25sKeep 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

System Design

Real-Time Notification System

Microservices

Redis Caching Patterns

Spring Boot

Spring WebFlux vs MVC

System Design

Chat System Design

Back to Blog Last updated: April 11, 2026