Skip to content
CalliCoder

Chat Application with Spring Boot and WebSocket

Published Updated Spring Boot 13 min read

STOMP over WebSocket end to end: broker configuration, @MessageMapping, presence via session events, a browser client on @stomp/stompjs, and why the in-memory broker stops working the moment you run two instances.

A chat application is the canonical WebSocket example because it needs the thing HTTP cannot do: the server pushing to clients that did not ask. Spring’s messaging support makes the server side short, and there is one architectural fact that decides whether what you build survives a second replica.

Written against Spring Boot 3.2, Java 17 and @stomp/stompjs 7.

Raw WebSocket, or STOMP

A raw WebSocket is a bidirectional pipe carrying bytes. Everything above that (who a message is for, what kind it is, how a client subscribes to a topic), you invent yourself.

STOMP is a small text protocol that adds those semantics, and Spring implements it. You get destinations, subscriptions, a message broker abstraction and @MessageMapping handlers that look like controllers. For anything with more than one kind of message, take STOMP.

Dependency

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

Configuration

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                .setAllowedOriginPatterns("http://localhost:*")
                .withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic", "/queue");
        registry.setApplicationDestinationPrefixes("/app");
        registry.setUserDestinationPrefix("/user");
    }
}

The two prefixes do different jobs and mixing them up is the most common configuration error.

setApplicationDestinationPrefixes("/app"): messages a client sends to be handled by your code. A client publishing to /app/chat.send reaches @MessageMapping("/chat.send").

enableSimpleBroker("/topic"), destinations the broker broadcasts on. Clients subscribe to /topic/public; your handler’s return value is published there.

Send to /topic/... from a client and nothing happens: the broker relays it to subscribers without your handler ever seeing it, which is occasionally what you want and usually a bug.

setAllowedOriginPatterns rather than setAllowedOrigins, the pattern form is what allows a wildcard port, which is what a development front-end needs. Do not ship *.

The message

public record ChatMessage(
        MessageType type,
        String content,
        String sender,
        Instant timestamp) {

    public enum MessageType { CHAT, JOIN, LEAVE }

    public static ChatMessage chat(String sender, String content) {
        return new ChatMessage(MessageType.CHAT, content, sender, Instant.now());
    }
}

A record is enough. Jackson serialises it, and with spring.jackson.serialization.write-dates-as-timestamps=false the instant goes out as ISO-8601 rather than a number.

The controller

@Controller
public class ChatController {

    @MessageMapping("/chat.send")
    @SendTo("/topic/public")
    public ChatMessage send(@Payload ChatMessage message) {
        return ChatMessage.chat(message.sender(), message.content());
    }

    @MessageMapping("/chat.join")
    @SendTo("/topic/public")
    public ChatMessage join(@Payload ChatMessage message,
                            SimpMessageHeaderAccessor headerAccessor) {
        headerAccessor.getSessionAttributes().put("username", message.sender());
        return new ChatMessage(ChatMessage.MessageType.JOIN, null,
                              message.sender(), Instant.now());
    }
}

@MessageMapping maps a client destination; @SendTo publishes the return value to a broker destination. Rebuilding the message rather than echoing the client’s object matters, the server sets the timestamp, so a client cannot claim to have sent something an hour ago.

Storing the username in the STOMP session attributes is what makes the disconnect handler below possible. There is no request to hang it on.

To send from anywhere else: a scheduled job, a REST endpoint, a service reacting to a database change — inject the template:

@Service
public class Announcements {

    private final SimpMessagingTemplate messaging;

    public Announcements(SimpMessagingTemplate messaging) {
        this.messaging = messaging;
    }

    public void broadcast(String text) {
        messaging.convertAndSend("/topic/public",
                new ChatMessage(ChatMessage.MessageType.CHAT, text, "system", Instant.now()));
    }

    public void toUser(String username, String text) {
        messaging.convertAndSendToUser(username, "/queue/notices",
                new ChatMessage(ChatMessage.MessageType.CHAT, text, "system", Instant.now()));
    }
}

convertAndSendToUser resolves against the user destination prefix, so the client subscribes to /user/queue/notices and Spring routes it to that user’s session.

Presence

A user who closes a tab sends no “leave” message. The event listener is the only reliable notice:

@Component
public class WebSocketEventListener {

    private static final Logger log = LoggerFactory.getLogger(WebSocketEventListener.class);
    private final SimpMessagingTemplate messaging;

    public WebSocketEventListener(SimpMessagingTemplate messaging) {
        this.messaging = messaging;
    }

    @EventListener
    public void onDisconnect(SessionDisconnectEvent event) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
        String username = (String) accessor.getSessionAttributes().get("username");
        if (username == null) {
            return;
        }
        log.info("{} disconnected", username);
        messaging.convertAndSend("/topic/public",
                new ChatMessage(ChatMessage.MessageType.LEAVE, null, username, Instant.now()));
    }
}

SessionDisconnectEvent fires on a closed tab, a dropped network and a browser crash alike. SessionConnectedEvent is its counterpart, though the join message above is usually more useful because it carries a name.

The browser client

npm install @stomp/stompjs sockjs-client
import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';

const client = new Client({
  webSocketFactory: () => new SockJS('http://localhost:8080/ws'),
  reconnectDelay: 5000,
  onConnect: () => {
    client.subscribe('/topic/public', (frame) => {
      render(JSON.parse(frame.body));
    });

    client.publish({
      destination: '/app/chat.join',
      body: JSON.stringify({ sender: username, type: 'JOIN' }),
    });
  },
  onStompError: (frame) => console.error('broker error', frame.headers.message),
});

client.activate();

function sendMessage(text) {
  client.publish({
    destination: '/app/chat.send',
    body: JSON.stringify({ sender: username, content: text, type: 'CHAT' }),
  });
}

Two notes on the client library. The old global Stomp.over(...) API from stomp-websocket is obsolete; @stomp/stompjs with a Client object is the maintained one. And reconnectDelay is why you use a library rather than the raw WebSocket object: networks drop, phones sleep, and reconnect-with-resubscribe is more code than it looks.

.withSockJS() on the server plus SockJS on the client gives a fallback for environments where a proxy breaks WebSocket upgrades. Every current browser speaks WebSocket natively, so if you control the network path you can drop SockJS and connect to ws://host/ws directly.

The part that decides your architecture

enableSimpleBroker is an in-memory broker. Subscriptions and destinations live in the heap of one JVM.

Run two instances behind a load balancer and each has its own broker. Alice connects to instance A, Bob to instance B, Alice sends a message: instance A broadcasts to its own subscribers, and Bob never receives it. Everything works in development and half the messages vanish in production.

The fix is to stop being the broker and relay to a real one:

@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
    registry.enableStompBrokerRelay("/topic", "/queue")
            .setRelayHost("rabbitmq")
            .setRelayPort(61613)
            .setClientLogin("guest")
            .setClientPasscode("guest");
    registry.setApplicationDestinationPrefixes("/app");
}

RabbitMQ with the STOMP plugin, or ActiveMQ. Both instances now publish to and subscribe from shared infrastructure, so a message reaches every subscriber regardless of which instance holds their socket. Requires spring-boot-starter-reactor-netty for the TCP client.

Sticky sessions are not a fix. They keep a client on one instance and do nothing about a message that needs to reach subscribers on the other.

Security

The HTTP handshake carries cookies, so an authenticated session is available at connect time and Principal is injected into handlers. What is not automatic is authorisation per destination:

@Configuration
@EnableWebSocketSecurity
public class WebSocketSecurityConfig {

    @Bean
    AuthorizationManager<Message<?>> messageAuthorizationManager(
            MessageMatcherDelegatingAuthorizationManager.Builder messages) {
        return messages
                .simpDestMatchers("/app/**").authenticated()
                .simpSubscribeDestMatchers("/topic/public").authenticated()
                .simpSubscribeDestMatchers("/user/**").authenticated()
                .anyMessage().denyAll()
                .build();
    }
}

Without something like this, any connected client can subscribe to any destination it can name, including another user’s queue. denyAll() as the fallback rather than permitAll(). An unmatched destination should be refused, not allowed.

Frequently asked questions

Do I need STOMP, or is a raw WebSocket enough?

Raw WebSocket is a byte pipe; you would build destinations, subscriptions and message types yourself. Once there is more than one kind of message, STOMP is less work.

What is the difference between /app and /topic?

/app is the application prefix, messages clients send to your @MessageMapping handlers. /topic is a broker destination clients subscribe to. Publishing to /topic from a client bypasses your handlers entirely.

Why does nothing arrive when I publish to /topic?

The broker relays it straight to subscribers without invoking your code. Publish to /app/... so a handler runs and decides what to broadcast.

How do I detect a user closing the tab?

Listen for SessionDisconnectEvent. No message is sent by a closing browser, so an event listener is the only reliable signal. Stash the username in the STOMP session attributes on join so the handler knows who left.

How do I send to one specific user?

convertAndSendToUser(username, "/queue/notices", payload), with the client subscribing to /user/queue/notices. Spring maps the user prefix to that session.

Why do messages disappear when I scale to two instances?

enableSimpleBroker keeps subscriptions in one JVM’s memory, so each instance broadcasts only to its own clients. Use enableStompBrokerRelay against RabbitMQ or ActiveMQ.

Will sticky sessions fix that?

No. They pin a client to an instance but do nothing to deliver a message to subscribers connected elsewhere.

Do I still need SockJS?

Only for network paths that break WebSocket upgrades, some corporate proxies. Every current browser supports WebSocket natively.

Should I use Stomp.over() from stomp-websocket?

No, that library is obsolete. Use @stomp/stompjs and its Client object, which also handles reconnection.

How do I authenticate a WebSocket connection?

The handshake is an HTTP request, so an existing session or token is available then and Principal is injected into handlers. Add @EnableWebSocketSecurity with destination matchers for per-destination authorisation.

Can I send messages from a REST controller?

Yes, inject SimpMessagingTemplate and call convertAndSend. That is the usual way a database change or scheduled job reaches connected clients.

Where should I go next?

Spring Boot REST API covers the HTTP side of the same application, and the Spring Boot guides cover the rest of the stack.