namalzi

← All posts

Digging into Spring's Source 25: WebSocket and Messaging — How a STOMP Message Flows

Spring · Published

Follows one STOMP message from the browser through an @MessageMapping method and out to every subscriber, in the source. The Message, channel and handler abstractions of spring-messaging, the handshake in spring-websocket, and the three channels and simple broker that connect them — explained for someone who has never seen WebSocket before.

What this post covers

This is the twenty-fifth post in the "Digging into Spring's Source" series. Up to Part 24 we looked at HTTP, where one request gets one response and that is the end of it. This post is U23, the last unit of Phase 5 (web) on the map in Part 1: WebSocket, where a connection stays open and either side can speak at any time, and the messaging layer that carries messages over it. We follow how a line typed by one person in a chat room ends up on the screen of everyone in that room.

There are four questions to answer.

Item Value
Source Spring Framework 7.1.0-SNAPSHOT
Commit f447f3c310 (2026-10-01, main branch)
Modules read spring-messaging, spring-websocket
Classes read ExecutorSubscribableChannel, SubProtocolWebSocketHandler, StompSubProtocolHandler, SimpAnnotationMethodMessageHandler, SimpleBrokerMessageHandler, AbstractMessageBrokerConfiguration

Every line number and every detail in this post refers to that commit. In other versions the line numbers will differ and some details may too. Part 1 explains how to get the source at the same commit.

This post keeps only the main thread that someone new to WebSocket and STOMP can follow. What was left out is listed by name and location at the end, under "What this post left out".

Words to know first

WebSocket is a way for a browser and a server to keep one connection open so that either side can send data at any time. With HTTP the server only answers when the client asks; with WebSocket the server can speak first. It is used for chat and live notifications.

The handshake is the procedure that opens that connection. A WebSocket connection begins as an ordinary HTTP request. The client sends a header saying "let's switch this connection to WebSocket" (Upgrade: websocket), and if the server agrees, the same connection is used as a WebSocket from then on.

STOMP is an agreement about the format of the text sent over a WebSocket. WebSocket itself only says "a chunk of text is sent"; it says nothing about what the chunk means. STOMP calls that chunk a frame and lays it out as a command on the first line (CONNECT, SUBSCRIBE, SEND, MESSAGE, ...), then name:value headers, an empty line, and a body. An agreement layered on top of WebSocket like this is called a sub-protocol.

A destination is the address written in a STOMP frame's destination header. It looks like a path, such as /topic/greetings, but it is not a URL — it is a name for "where this message should go" or "what I want to receive".

Publish/subscribe (pub/sub) means the sender does not name a recipient. It puts a message out at a destination (publish), and everyone who said in advance that they want that destination (subscribe) receives it. The part that remembers who subscribed to what and hands messages out is called a broker.

A handler method is a method of ours that Spring calls when something arrives. In HTTP it was a method annotated with @RequestMapping, and Part 22 covers how Spring finds such a method, fills in its arguments and calls it. @MessageMapping in this post is the same structure applied to messages.

1. First foundation: Message, channel, handler

The spring-messaging module knows nothing about WebSocket. At its base are three very general interfaces that say "put a message somewhere, and someone will receive and handle it".

public interface Message<T> {

	/**
	 * Return the message payload.
	 */
	T getPayload();

	/**
	 * Return message headers for the message (never {@code null} but may be empty).
	 */
	MessageHeaders getHeaders();

}

spring-messaging/src/main/java/org/springframework/messaging/Message.java, lines 28–40

A Message is just two things: a payload and headers (MessageHeaders). MessageHeaders is a Map<String, Object> that cannot be modified — calling put throws (MessageHeaders lines 265–268). An id and a timestamp header are added automatically when it is created (lines 136–158).

The other two have one method each to implement (MessageChannel has a single abstract send(message, timeout); the send(message) in the table below is a default method that calls it).

Interface Method Meaning
MessageChannel send(message) the slot you drop a message into
MessageHandler handleMessage(message) the side that receives and handles it
SubscribableChannel subscribe(handler), unsubscribe(handler) a channel that handlers can be attached to and detached from

The sender knows only the channel; it does not know who receives the message or how many receivers there are. All the big parts in this post are MessageHandlers, and they never call each other directly — they are connected only by putting messages into channels.

This resembles the ApplicationEvent from Part 12, but it is different. With events there is one road per container (the multicaster) and listeners are chosen by the type of the event. Channels can be created in any number to separate roads, and every handler attached to a channel receives the message and then decides for itself, from the headers (mostly the destination), whether it is its job.

What happens in one send

Every channel in this post is the same class, ExecutorSubscribableChannel. send lives in its parent AbstractMessageChannel (lines 128–157) and calls preSend → sendInternal → postSend → afterSendCompletion in that order. What gets a turn before and after is the ChannelInterceptor (lines 35–95). If preSend returns null, send ends right there with false and no handler is called (lines 177–193). This is where you hook in decisions like authentication — "should this message be let in?"

sendInternal, which actually calls the handlers, is short.

	@Override
	public boolean sendInternal(Message<?> message, long timeout) {
		for (MessageHandler handler : getSubscribers()) {
			SendTask sendTask = new SendTask(message, handler);
			if (this.executor != null) {
				try {
					this.executor.execute(sendTask);
				}
				catch (RejectedExecutionException ex) {
					// Probably on shutdown -> run send task locally instead
					sendTask.run();
				}
			}
			else {
				// No executor configured -> always run send tasks locally
				sendTask.run();
			}
		}
		return true;
	}

spring-messaging/src/main/java/org/springframework/messaging/support/ExecutorSubscribableChannel.java, lines 95–114

It creates one SendTask per subscribed handler and, if the channel has an Executor (a thread pool), hands the task to it; otherwise it runs the task on the spot. The subscriber list is a CopyOnWriteArraySet (AbstractSubscribableChannel line 34).

The flow of sending a message to a channel. If an interceptor returns null from preSend, send returns false and no handler is called. Otherwise sendInternal creates a SendTask for each subscribed handler; if the channel has no Executor the sending thread calls handleMessage, and if it has one a pool thread does. Then postSend and afterSendCompletion run and send returns true. Blue marks where interceptors get a turn. On a channel with an Executor, the handler may be called after send has already returned.

Example 1: one channel with an interceptor and handlers

I created channels on their own, with no web server, and ran them. This example was run on the released Spring Framework 7.0.9 and produced the output below (the 7.1.0-SNAPSHOT this series reads was not built and run directly). PrintingInterceptor prints one line with the thread name each time a hook is called, and BlockingInterceptor returns null from preSend when the payload is "blocked". (The real example prints Korean; the labels and payload strings are translated here.)

// An example written for this post (not Spring's code)
        ExecutorSubscribableChannel direct = new ExecutorSubscribableChannel();
        direct.addInterceptor(new PrintingInterceptor());
        direct.subscribe(message -> log("handler A          " + message.getPayload()));
        direct.subscribe(message -> log("handler B          " + message.getPayload()));
        boolean sent = direct.send(hello);

        // ...
            ExecutorSubscribableChannel async = new ExecutorSubscribableChannel(pool);
            async.addInterceptor(new PrintingInterceptor());
            async.subscribe(message -> {
                log("handler A          " + message.getPayload());
                handled.countDown();
            });
            // ...
            sent = async.send(hello);

        // ...
        ExecutorSubscribableChannel guarded = new ExecutorSubscribableChannel();
        guarded.addInterceptor(new BlockingInterceptor());
        guarded.subscribe(message -> log("handler A          " + message.getPayload()));
        System.out.println("  send(\"pass\") returned " + guarded.send(MessageBuilder.withPayload("pass").build()));
        System.out.println("  send(\"blocked\") returned " + guarded.send(MessageBuilder.withPayload("blocked").build()));
header keys of the message: [destination, id, timestamp]
trying to modify the headers: MessageHeaders is immutable

== 1) Channel without an Executor: the sending thread calls the handlers too ==
  [main  ] preSend            payload=hello
  [main  ] beforeHandle
  [main  ] handler A          hello
  [main  ] afterMessageHandled
  [main  ] beforeHandle
  [main  ] handler B          hello
  [main  ] afterMessageHandled
  [main  ] postSend           sent=true
  [main  ] afterSendCompletion
  send() returned true

== 2) Channel with an Executor: the handler runs on another thread ==
  [main  ] preSend            payload=hello
  [main  ] postSend           sent=true
  [main  ] afterSendCompletion
  send() returned true (the handler has not been called yet)
  [pool-1] beforeHandle
  [pool-1] handler A          hello
  [pool-1] afterMessageHandled

== 3) If preSend returns null the message never enters the channel ==
  [main  ] handler A          pass
  send("pass") returned true
  send("blocked") returned false

In 2) I held the pool thread back until send returned, so that the output order is the same on every run. Without that, the [pool-1] lines sometimes print before the send() returned line.

Easy to get wrong

2. Second foundation: WebSocketHandler and the handshake

The base of the spring-websocket module is the WebSocketHandler interface (lines 35–80). Its methods follow the life of one connection.

Method When it is called
afterConnectionEstablished(session) the handshake finished and the connection is open
handleMessage(session, message) a WebSocket message from the other side arrived
handleTransportError(session, exception) an error occurred in transport
afterConnectionClosed(session, closeStatus) after the connection closed

A WebSocketSession is one open connection (line 38). It is identified by getId(), and sendMessage(message) sends to the other side (line 130). The server can speak first precisely because it holds on to this session object.

Where an HTTP request is upgraded to WebSocket

Since the connection starts as an HTTP request, the entrance is the DispatcherServlet from Part 21. The WebSocket endpoint path (for example /ws) is registered in a WebSocketHandlerMapping (line 40, a subclass of SimpleUrlHandlerMapping), and the handler mapped to that path is a WebSocketHttpRequestHandler.

	@Override
	public void handleRequest(HttpServletRequest servletRequest, HttpServletResponse servletResponse)
			throws ServletException, IOException {

		ServerHttpRequest request = new ServletServerHttpRequest(servletRequest);
		ServerHttpResponse response = new ServletServerHttpResponse(servletResponse);

		HandshakeInterceptorChain chain = new HandshakeInterceptorChain(this.interceptors, this.wsHandler);
		// ...
		try {
			// ...
			Map<String, Object> attributes = new HashMap<>();
			if (!chain.applyBeforeHandshake(request, response, attributes)) {
				return;
			}
			this.handshakeHandler.doHandshake(request, response, this.wsHandler, attributes);
			chain.applyAfterHandshake(request, response, null);
		}
		// ...
	}

spring-websocket/src/main/java/org/springframework/web/socket/server/support/WebSocketHttpRequestHandler.java, lines 159–195

Three lines are the skeleton. Call beforeHandshake on the HandshakeInterceptors (stop if any returns false), hand over to HandshakeHandler.doHandshake, then call afterHandshake. The attributes map holds values that an interceptor can fill in and that can later be read with WebSocketSession.getAttributes().

The default implementation of doHandshake is in AbstractHandshakeHandler (lines 168–230). It does several checks and one delegation.

  1. If the HTTP method is not GET (or CONNECT, for HTTP/2): 405 (lines 177–185).
  2. For GET, it checks that the Upgrade header is WebSocket, that the Connection header contains Upgrade, and that Sec-WebSocket-Key is present. Otherwise: 400 (lines 186–204).
  3. It checks the WebSocket version and the Origin. A disallowed origin gets 403 (lines 205–212).
  4. From the sub-protocols the client asked for, it picks one that the handler supports (line 219, lines 297–308). This is where STOMP gets selected.
  5. It hands over to RequestUpgradeStrategy.upgrade(...) (line 228).

Actually switching the connection to WebSocket is done not by Spring but by the server (Tomcat and so on). The strategy DefaultHandshakeHandler picks is JettyRequestUpgradeStrategy if Jetty's WebSocket server is on the classpath and StandardWebSocketUpgradeStrategy otherwise (line 45). Taking the latter, it calls the servlet container's ServerContainer.upgradeHttpToWebSocket(...) (line 138) and passes it a StandardWebSocketHandlerAdapter, which wraps our WebSocketHandler in the standard API (jakarta.websocket.Endpoint) (line 119). When the connection opens, the server calls that adapter's onOpen, and the adapter calls afterConnectionEstablished (StandardWebSocketHandlerAdapter lines 60–103).

Example 4 in section 6 checks this flow by actually connecting to an embedded Tomcat. Its output shows the handshake request passing through DispatcherServlet and reaching the interceptor's beforeHandshake and afterHandshake, the response status becoming 101, a sub-protocol being picked, and StandardWebSocketUpgradeStrategy being the chosen strategy.

The handshake flow. WebSocketHandlerMapping routes the client's HTTP GET request to WebSocketHttpRequestHandler. If HandshakeInterceptor.beforeHandshake returns false it stops; if true, DefaultHandshakeHandler.doHandshake checks the method, headers and Origin. If they pass, RequestUpgradeStrategy asks the server to switch the connection to WebSocket, and WebSocketHandler.afterConnectionEstablished is called. From the green box on, it is no longer HTTP. Messages that arrive afterwards do not go through DispatcherServlet.

Easy to get wrong

3. Where STOMP is layered on

What WebSocketHandler.handleMessage receives is just a chunk of text. Two classes form the bridge that reads the chunk as a STOMP frame and turns it into the Message of section 1.

SubProtocolWebSocketHandler has two methods named handleMessage.

	@Override
	public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {
		WebSocketSessionHolder holder = this.sessions.get(session.getId());
		if (holder != null) {
			session = holder.getSession();
		}
		SubProtocolHandler protocolHandler = findProtocolHandler(session);
		protocolHandler.handleMessageFromClient(session, message, this.clientInboundChannel);
		if (holder != null) {
			holder.setHasHandledMessages();
		}
	}

	// ...
	@Override
	public void handleMessage(Message<?> message) throws MessagingException {
		String sessionId = resolveSessionId(message);
		// ...
		WebSocketSession session = holder.getSession();
		try {
			findProtocolHandler(session).handleMessageToClient(session, message);
		}
		// ...
	}

spring-websocket/src/main/java/org/springframework/web/socket/messaging/SubProtocolWebSocketHandler.java, lines 347–408

The first, as a WebSocketHandler, receives what the client sent; the second, as a MessageHandler, receives what should be sent to a client. Either way it finds the SubProtocolHandler that matches the sub-protocol the session chose during the handshake (lines 443–475) and passes the work on. For the second one to be called, someone has to subscribe this object to a channel — it subscribes itself to clientOutboundChannel when it starts (lines 285–293).

In the incoming direction, StompSubProtocolHandler looks like this.

	public void handleMessageFromClient(WebSocketSession session,
			WebSocketMessage<?> webSocketMessage, MessageChannel channel) {

		// ...
			messages = decoder.decode(byteBuffer);
		// ...
		for (Message<byte[]> message : messages) {
			StompHeaderAccessor headerAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
			// ...
				headerAccessor.setSessionId(sessionId);
				headerAccessor.setSessionAttributes(session.getAttributes());
				headerAccessor.setUser(getUser(session));
				// ...
					sent = channelToUse.send(message);
				// ...
		}
	}

spring-websocket/src/main/java/org/springframework/web/socket/messaging/StompSubProtocolHandler.java, lines 248–378

  1. decoder.decode cuts the bytes into STOMP frames and produces a list of Message<byte[]>. The frame's command and headers go inside the Message headers, and the body becomes the payload (StompDecoder, line 50).
  2. It writes the session id, session attributes and user into the headers. A STOMP frame does not say who sent it; the WebSocket connection knows that. The parts after this point know nothing about WebSocketSession and look only at the session id in the headers.
  3. It sends to the channel. Here channel is the clientInboundChannel passed in the code above.

After sending, for CONNECT, SUBSCRIBE and UNSUBSCRIBE it also publishes an ApplicationEvent such as SessionConnectEvent (lines 340–353). You can receive these with the @EventListener from Part 12.

A STOMP command is kept in the headers in two forms: the original command (StompCommand) and a coarser kind that parts unaware of STOMP can also recognise (SimpMessageType). The mapping is written in the StompCommand enum (lines 31–47).

STOMP command SimpMessageType
CONNECT, STOMP CONNECT
SUBSCRIBE SUBSCRIBE
UNSUBSCRIBE UNSUBSCRIBE
SEND (client → server), MESSAGE (server → client) MESSAGE
DISCONNECT DISCONNECT

The outgoing direction is the reverse. handleMessageToClient turns the Message headers into STOMP headers (lines 458–515), builds the bytes with StompEncoder, and writes them out with session.sendMessage(...) (lines 517–529).

The two directions joined by SubProtocolWebSocketHandler and StompSubProtocolHandler. Inbound: a STOMP frame (text) sent by the client is received by SubProtocolWebSocketHandler.handleMessage(session, WebSocketMessage) as a WebSocketHandler; StompSubProtocolHandler.handleMessageFromClient ① turns it into a list of Message<byte[]> with decoder.decode (command and headers into headers, body into payload), ② writes the session id, session attributes and user into the headers, and ③ sends it with clientInboundChannel.send(message). Outbound: from clientOutboundChannel, which it subscribes to on startup, SubProtocolWebSocketHandler.handleMessage(Message) receives a message as a MessageHandler; StompSubProtocolHandler.handleMessageToClient turns the headers into STOMP headers, makes bytes with StompEncoder, and sends the STOMP frame to the client with session.sendMessage The same class receives as a WebSocketHandler and sends as a MessageHandler. The yellow boxes are the channels we look at in Section 4.

Easy to get wrong

4. The three channels and the handlers attached to them

This is the centre of the post. Between the bridge of section 3 and our code there are three ExecutorSubscribableChannels.

Channel (bean name) What passes through Handlers subscribed
clientInboundChannel every message sent by a client SimpAnnotationMethodMessageHandler, SimpleBrokerMessageHandler, UserDestinationMessageHandler
brokerChannel messages server-side code sends to the broker SimpleBrokerMessageHandler, UserDestinationMessageHandler
clientOutboundChannel every message going out to a client SubProtocolWebSocketHandler

Each handler subscribes in its start() (SimpAnnotationMethodMessageHandler lines 306–312, AbstractBrokerMessageHandler lines 215–228, UserDestinationMessageHandler lines 171–178). The subscriber lists in the table were printed and confirmed by example 3 in section 6.

How the three channels and the handlers are connected. SubProtocolWebSocketHandler turns an incoming frame into a Message and puts it on clientInboundChannel. SimpAnnotationMethodMessageHandler, UserDestinationMessageHandler and SimpleBrokerMessageHandler subscribe to that channel. The return value of the annotation handler, the result of the user destination handler, and SimpMessagingTemplate from server code all go into brokerChannel; the broker receives them and puts one message per subscriber on clientOutboundChannel, where SubProtocolWebSocketHandler turns them into frames and sends them out. Yellow boxes are channels, blue boxes are handlers subscribed to a channel. The SubProtocolWebSocketHandler at the top and at the bottom is the same object.

The three handlers on clientInboundChannel receive every message. Each then looks at the beginning of the destination (the prefix) and handles only its own. We look at them one by one, assuming the usual setup (/app goes to our methods, /topic goes to the broker).

SimpAnnotationMethodMessageHandler — the one that calls @MessageMapping

One class does what RequestMappingHandlerMapping and RequestMappingHandlerAdapter did in Part 22. Its parent is AbstractMethodMessageHandler.

In HTTP the return value became the response body. Messaging has no "response" as such, so the return value becomes a new message that is sent somewhere. SendToMethodReturnValueHandler does that.

With @SendTo("/topic/greetings") it goes to that destination; without it, to /topic plus the incoming destination minus its prefix (the default prefix /topic is at line 71, the combining at lines 245–260). To send, it calls messagingTemplate.convertAndSend(destination, returnValue, ...) (lines 209–216), and that template is the one that sends to brokerChannel (lines 372–373).

SimpleBrokerMessageHandler — the one that remembers subscriptions and fans out

This is the simple broker built into Spring. It subscribes to both clientInboundChannel and brokerChannel, and deals only with destinations that start with its own prefix (/topic). handleMessageInternal, which receives messages, branches on the kind (lines 292–350).

The default registry implementation is DefaultSubscriptionRegistry, and it has two stores (lines 91–93).

Store Shape Use
SessionRegistry session id → that session's subscriptions (subscription id, destination or pattern) the original. Changed on SUBSCRIBE, UNSUBSCRIBE and disconnect (lines 393–414)
DestinationCache destination → (session id → subscription ids) a computed result, so that not everything is scanned for every message. Up to 1024 entries by default (lines 258–296, line 73)

A subscription destination can also be a pattern such as /topic/*; matching is done with AntPathMatcher (lines 283–296).

The code that hands messages out to subscribers looks like this.

	protected void sendMessageToSubscribers(@Nullable String destination, Message<?> message) {
		MultiValueMap<String,String> subscriptions = this.subscriptionRegistry.findSubscriptions(message);
		// ...
		subscriptions.forEach((sessionId, subscriptionIds) -> {
			for (String subscriptionId : subscriptionIds) {
				SimpMessageHeaderAccessor headerAccessor = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE);
				initHeaders(headerAccessor);
				headerAccessor.setSessionId(sessionId);
				headerAccessor.setSubscriptionId(subscriptionId);
				headerAccessor.copyHeadersIfAbsent(message.getHeaders());
				// ...
				Object payload = message.getPayload();
				Message<?> reply = MessageBuilder.createMessage(payload, headerAccessor.getMessageHeaders());
				SessionInfo info = this.sessions.get(sessionId);
				if (info != null) {
					try {
						info.getClientOutboundChannel().send(reply);
					}
					// ...
				}
			}
		});
	}

spring-messaging/src/main/java/org/springframework/messaging/simp/broker/SimpleBrokerMessageHandler.java, lines 391–423

It creates a new Message for each subscription. The payload stays as it is; the headers get the receiving session id and subscription id, and the message is put on clientOutboundChannel. With 100 subscribers, 100 messages pass through the outbound channel, and the SubProtocolWebSocketHandler of section 3 looks up the WebSocketSession by the session id in the headers and sends to them one by one.

Example 2: a broker on its own, fed with messages

I created a SimpleBrokerMessageHandler directly, attached it to three channels, and instead of STOMP frames fed it hand-built Messages of the same shape (same environment as example 1; labels and payloads translated as before). A handler that only prints is attached to the outbound channel. The channels have no Executor, so everything runs in order on the main thread. Each step in the output below is one or two calls to clientInboundChannel.send(...); only 5) is new SimpMessagingTemplate(brokerChannel).convertAndSend(...).

clientInboundChannel subscribers = 1, brokerChannel subscribers = 1

== 1) SUBSCRIBE and send without CONNECT (session s0) ==
  (nothing was put on the outbound channel)

== 2) Two sessions CONNECT ==
  out → type=CONNECT_ACK    session=s1 subscription=null  destination=null           payload=
  out → type=CONNECT_ACK    session=s2 subscription=null  destination=null           payload=

== 3) s1 subscribes to /topic/news, s2 to the pattern /topic/* ==
  (the subscriptions are only written to the registry; no outgoing message)

== 4) A client sends to /topic/news (clientInboundChannel) ==
  out → type=MESSAGE        session=s1 subscription=sub-1 destination=/topic/news    payload=second news
  out → type=MESSAGE        session=s2 subscription=sub-2 destination=/topic/news    payload=second news

== 5) Server code sends with SimpMessagingTemplate (brokerChannel) ==
  out → type=MESSAGE        session=s2 subscription=sub-2 destination=/topic/weather payload=sunny

== 6) Send to /app/hello, which is not a broker prefix ==
  (nothing was put on the outbound channel)

== 7) s1 DISCONNECTs, then another message goes to /topic/news ==
  out → type=DISCONNECT_ACK session=s1 subscription=null  destination=null           payload=
  out → type=MESSAGE        session=s2 subscription=sub-2 destination=/topic/news    payload=third news

In 4) one message goes out as two. In 5) only s2, which subscribed with a pattern, receives /topic/weather. In 7) s1's subscription was removed together with the DISCONNECT (lines 375–389).

Easy to get wrong

The other two: user destinations and an external broker

UserDestinationMessageHandler deals with destinations that start with /user/.. (lines 203–240). It turns a destination meaning "only this user" into a real destination unique to each of that user's sessions (the default implementation appends -user plus the session id, DefaultUserDestinationResolver lines 243–247) and puts the message back on brokerChannel. After that it is ordinary subscribe and publish.

StompBrokerRelayMessageHandler is used in place of the simple broker (line 82). It does not remember subscriptions itself; it relays messages to an external STOMP broker server. It extends the same parent (AbstractBrokerMessageHandler) and subscribes to the same two channels, so the other parts do not change. This post only names it.

5. Who creates these beans — @EnableWebSocketMessageBroker

We never created the channels and handlers above ourselves. They appear when one annotation is put on a configuration class.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import(DelegatingWebSocketMessageBrokerConfiguration.class)
public @interface EnableWebSocketMessageBroker {

}

spring-websocket/src/main/java/org/springframework/web/socket/config/annotation/EnableWebSocketMessageBroker.java, lines 63–69

As Part 9 showed, an @EnableXxx is an @Import. The configuration class it brings in has three layers.

The three layers of configuration classes. EnableWebSocketMessageBroker imports DelegatingWebSocketMessageBrokerConfiguration, which extends WebSocketMessageBrokerConfigurationSupport, which extends AbstractMessageBrokerConfiguration. The spring-websocket layer creates the subProtocolWebSocketHandler and stompWebSocketHandlerMapping beans; the spring-messaging layer creates the three channels, the handlers and brokerMessagingTemplate. The lower the layer, the less it knows about WebSocket. Channels and the broker are in spring-messaging; the beans that touch WebSocket are in spring-websocket.

Layer What it does
DelegatingWebSocketMessageBrokerConfiguration gets every WebSocketMessageBrokerConfigurer bean in the container injected, and asks them in turn whenever a configuration hook is called (lines 43–54)
WebSocketMessageBrokerConfigurationSupport subProtocolWebSocketHandler, and stompWebSocketHandlerMapping, which maps endpoint paths to that handler (lines 93–123)
AbstractMessageBrokerConfiguration the three channels, the handlers of section 4, the message converter, SimpMessagingTemplate

When the registerStompEndpoints of our WebSocketMessageBrokerConfigurer calls addEndpoint("/ws"), at that moment the StompSubProtocolHandler is registered with the SubProtocolWebSocketHandler (lines 103–110) and the WebSocketHttpRequestHandler of section 2 is mapped to that path (lines 170–187).

The method that creates a channel bean shows the class from section 1 unchanged. It creates a new ExecutorSubscribableChannel(executor), attaches the registered interceptors, and returns it — nothing more (lines 157–168).

Easy to get wrong

6. The journey of one message, and SimpMessagingTemplate

With all the parts seen, let us connect them from start to finish. A client sends SEND to /app/hello, and the return value of the @MessageMapping("/hello") method goes to the subscribers of /topic/greetings as MESSAGE.

The journey of one message. StompSubProtocolHandler turns the client's SEND frame into a Message and puts it on clientInboundChannel; SimpAnnotationMethodMessageHandler strips the /app prefix and calls the method. SendToMethodReturnValueHandler sends the return value to brokerChannel, SimpleBrokerMessageHandler finds the subscribers and puts one message per subscription on clientOutboundChannel, and StompSubProtocolHandler turns each into a MESSAGE frame for the subscriber. The three yellow boxes are channels. The message crosses a channel three times, and its content and headers change in between.

SimpMessagingTemplate sends to brokerChannel

SimpMessagingTemplate is what you use to send a message to subscribers from outside a controller method — from a scheduled job or an HTTP controller, for example. The bean the configuration creates is this.

	@Bean
	public SimpMessagingTemplate brokerMessagingTemplate(
			AbstractSubscribableChannel brokerChannel, AbstractSubscribableChannel clientInboundChannel,
			AbstractSubscribableChannel clientOutboundChannel, CompositeMessageConverter brokerMessageConverter) {

		SimpMessagingTemplate template = new SimpMessagingTemplate(brokerChannel);
		MessageBrokerRegistry registry = getBrokerRegistry(clientInboundChannel, clientOutboundChannel);
		String prefix = registry.getUserDestinationPrefix();
		if (prefix != null) {
			template.setUserDestinationPrefix(prefix);
		}
		template.setMessageConverter(brokerMessageConverter);
		return template;
	}

spring-messaging/src/main/java/org/springframework/messaging/simp/config/AbstractMessageBrokerConfiguration.java, lines 483–496

The channel passed to the constructor is brokerChannel. convertAndSend(destination, payload) turns the payload into a Message with the converter (AbstractMessageSendingTemplate lines 154–160), writes the destination and the kind MESSAGE into the headers, and sends it to that channel (lines 148–191). In other words it enters the diagram above directly at ⑤. The template that sent the @SendTo return value in section 4 is this very bean.

Example 3: put frames in, see frames come out

I ran it end to end (same environment as example 1), in two passes. This first example runs without a web server. With no server, the handshake of section 2 does not happen (connecting to a real server is example 4, right after this one). Instead I wrote a fake session that implements WebSocketSession and called afterConnectionEstablished and handleMessage directly — the methods the server calls after a handshake. That way the text of the frames going in and coming out can be seen as it is. For configuration, instead of @EnableWebSocketMessageBroker I directly extended AbstractMessageBrokerConfiguration, the topmost ancestor of the configuration class that annotation imports (the third layer in section 5), and registered only a SubProtocolWebSocketHandler by hand. The two upper layers the annotation brings in also create the HTTP-side beans that map endpoint paths in a handler mapping, and an example that runs without a server has no use for those. From section 3 onwards the real classes run unchanged.

// An example written for this post (not Spring's code)
    // This is all the code we write: receive a message sent to /app/hello and send it back to /topic/greetings
    @Controller
    static class GreetingController {
        @MessageMapping("/hello")
        @SendTo("/topic/greetings")
        String greet(String name) {
            log("GreetingController.greet(\"" + name + "\")");
            return "안녕하세요, " + name + "!";   // "Hello, <name>!"
        }
    }

The configuration class calls only enableSimpleBroker("/topic") and setApplicationDestinationPrefixes("/app"). main feeds in frames like this.

// An example written for this post (not Spring's code)
            SubProtocolWebSocketHandler handler = context.getBean(SubProtocolWebSocketHandler.class);
            FakeSession session = new FakeSession();
            handler.afterConnectionEstablished(session);   // what the server calls once the handshake is done

            handler.handleMessage(session, new TextMessage("CONNECT\naccept-version:1.2\nhost:localhost\n\n\0"));
            printFrame(session);
            // ...
            handler.handleMessage(session, new TextMessage("SUBSCRIBE\nid:sub-0\ndestination:/topic/greetings\n\n\0"));
            // ...
            handler.handleMessage(session, new TextMessage(
                    "SEND\ndestination:/app/hello\ncontent-type:text/plain;charset=UTF-8\n\n남알지\0"));
            printFrame(session);
            // ...
            SimpMessagingTemplate template = context.getBean(SimpMessagingTemplate.class);
            System.out.println("  channel used by the template == brokerChannel ? " + (template.getMessageChannel() == broker));
            template.convertAndSend("/topic/greetings", "공지입니다");   // "This is a notice"
            printFrame(session);

Each channel has an interceptor that prints one line in preSend, and to keep the output order stable the inbound and outbound channels were reduced to one thread each. The labels below are translated, but the frames are shown exactly as the server produced them, Korean payloads included — the content-length values count their UTF-8 bytes, so translating the payloads would make the frames no longer match what the server sent. Example 4 below uses English strings throughout, and its output is shown untranslated.

clientInboundChannel subscribers: [SimpAnnotationMethodMessageHandler, SimpleBrokerMessageHandler, UserDestinationMessageHandler]
brokerChannel subscribers: [SimpleBrokerMessageHandler, UserDestinationMessageHandler]
clientOutboundChannel subscribers: [SubProtocolWebSocketHandler]

== 1) client → server: CONNECT ==
  [main                   ] → clientInboundChannel  type=CONNECT destination=null
  [clientInboundChannel-1 ] → clientOutboundChannel  type=CONNECT_ACK destination=null
  [main                   ] event SessionConnectEvent
  [clientOutboundChannel-1] event SessionConnectedEvent
  [clientOutboundChannel-1] WebSocketSession.sendMessage(TextMessage)
  server → client frame:
 CONNECTED
 version:1.2
 heart-beat:0,0
 
 ^@

== 2) client → server: SUBSCRIBE /topic/greetings ==
  [main                   ] → clientInboundChannel  type=SUBSCRIBE destination=/topic/greetings
  [main                   ] event SessionSubscribeEvent

== 3) client → server: SEND /app/hello ==
  [main                   ] → clientInboundChannel  type=MESSAGE destination=/app/hello
  [clientInboundChannel-1 ] GreetingController.greet("남알지")
  [clientInboundChannel-1 ] → brokerChannel  type=MESSAGE destination=/topic/greetings
  [clientInboundChannel-1 ] → clientOutboundChannel  type=MESSAGE destination=/topic/greetings
  [clientOutboundChannel-1] WebSocketSession.sendMessage(TextMessage)
  server → client frame:
 MESSAGE
 destination:/topic/greetings
 content-type:text/plain;charset=UTF-8
 subscription:sub-0
 message-id:s1-0
 content-length:27
 
 안녕하세요, 남알지!^@

== 4) server code sends with SimpMessagingTemplate.convertAndSend ==
  channel used by the template == brokerChannel ? true
  [main                   ] → brokerChannel  type=MESSAGE destination=/topic/greetings
  [main                   ] → clientOutboundChannel  type=MESSAGE destination=/topic/greetings
  [clientOutboundChannel-1] WebSocketSession.sendMessage(TextMessage)
  server → client frame:
 MESSAGE
 destination:/topic/greetings
 content-type:text/plain;charset=UTF-8
 subscription:sub-0
 message-id:s1-1
 content-length:15
 
 공지입니다^@

The context was closed.

^@ is the zero character that ends a STOMP frame, printed in a visible form. In 1), the lines from the main thread and the lines from other threads do not wait for each other, so their order can differ from run to run.

Following the thread names in 3) shows the diagram above as it is.

In 4) the main thread that called SimpMessagingTemplate starts at brokerChannel. It does not pass through clientInboundChannel.

Steps 3) and 4) of Example 3 followed by thread name. 3) The client's SEND /app/hello: the main thread puts a message with type=MESSAGE and destination /app/hello into clientInboundChannel; the clientInboundChannel-1 thread calls GreetingController.greet("남알지"), then on the same thread passes through brokerChannel (/topic/greetings) into clientOutboundChannel; the clientOutboundChannel-1 thread writes the MESSAGE frame '안녕하세요, 남알지!' (Hello, 남알지!) with sendMessage. 4) SimpMessagingTemplate.convertAndSend from server code: the main thread starts at brokerChannel (/topic/greetings) and puts the message into clientOutboundChannel; the clientOutboundChannel-1 thread writes the MESSAGE frame '공지입니다' (This is an announcement) with sendMessage. clientInboundChannel is not involved The name in brackets is the thread that did the work. Step 4), sent through the template, skips clientInboundChannel. All of it is the real output of Example 3 above; the Korean strings are the example's own payloads ("Hello, 남알지!" and "This is an announcement").

Example 4: a round trip with a real server and a real client

This time there is no fake session. I started an embedded Tomcat 11.0.15 on a random free port with a DispatcherServlet in it, and in the same process connected with WebSocketStompClient, Spring's STOMP client for Java, then subscribed, sent and received. This example, too, was run on the released Spring Framework 7.0.9 and produced the output below (the 7.1.0-SNAPSHOT this series reads was not built and run directly). The controller is the same as in example 3 except that it returns English text ("Hello, " + name + "!").

The configuration is exactly what section 5 described: one annotation and a WebSocketMessageBrokerConfigurer.

// An example written for this post (not Spring's code)
    // This time one annotation creates everything: the three channels, the handlers and the endpoint mapping
    @Configuration
    @EnableWebSocketMessageBroker
    static class Config implements WebSocketMessageBrokerConfigurer {

        @Override
        public void registerStompEndpoints(StompEndpointRegistry registry) {
            registry.addEndpoint("/ws").addInterceptors(new HandshakePrinter());
        }

        @Override
        public void configureMessageBroker(MessageBrokerRegistry registry) {
            registry.enableSimpleBroker("/topic");
            registry.setApplicationDestinationPrefixes("/app");
            registry.configureBrokerChannel().interceptors(new Trace("brokerChannel"));
        }

        // ...
        @Override
        public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
            // wrap the WebSocketHandler once to see the moments the connection opens and closes
            registry.addDecoratorFactory(ConnectionPrinter::new);
        }
    }

To see what passes where, printing is hooked in at four places. HandshakePrinter is a HandshakeInterceptor from section 2; it prints the request's Sec-WebSocket-Protocol header and the response's status code. ConnectionPrinter is a WebSocketHandler wrapped around SubProtocolWebSocketHandler; it prints a line when afterConnectionEstablished is called. Trace prints a line in preSend on each of the three channels, as in example 3, and on the outbound channel also right before a handler is called (beforeHandle). Finally, doService of DispatcherServlet is overridden to print a line for every request that passes. main looks like this.

// An example written for this post (not Spring's code)
        AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext();
        context.register(Config.class, GreetingController.class);
        // print a line every time a request passes through DispatcherServlet
        DispatcherServlet dispatcherServlet = new DispatcherServlet(context) {
            @Override
            protected void doService(HttpServletRequest request, HttpServletResponse response) throws Exception {
                // a servlet has a log(String) of its own, so call ours with the class name
                StompRoundTripExample.log("DispatcherServlet.doService  " + request.getMethod() + " " + request.getRequestURI());
                super.doService(request, response);
            }
        };

        Tomcat tomcat = new Tomcat();
        WebSocketStompClient stompClient = new WebSocketStompClient(new StandardWebSocketClient());
        stompClient.setMessageConverter(new StringMessageConverter());
        // ...
            tomcat.setPort(0);                                    // any free port
            // to keep the output order the same on every run, the request-processing threads are reduced to one as well
            tomcat.getConnector().setProperty("maxThreads", "1");
            tomcat.getConnector().setProperty("minSpareThreads", "1");
            Context tomcatContext = tomcat.addContext("", Files.createTempDirectory("u23-docbase").toString());
            // Tomcat's WebSocket initialization, which a container normally finds by scanning jars, is done by hand here
            tomcatContext.addServletContainerInitializer(new WsSci(), null);
            Tomcat.addServlet(tomcatContext, "dispatcher", dispatcherServlet).setLoadOnStartup(1);
            tomcatContext.addServletMappingDecoded("/", "dispatcher");
            tomcat.start();
            int port = tomcat.getConnector().getLocalPort();
            // ...
            // write the sub-protocols we want into the Sec-WebSocket-Protocol header of the handshake request
            WebSocketHttpHeaders handshakeHeaders = new WebSocketHttpHeaders();
            handshakeHeaders.setSecWebSocketProtocol(List.of("v12.stomp", "v11.stomp"));
            session = stompClient.connectAsync("ws://localhost:" + port + "/ws", handshakeHeaders, new ClientSessionHandler())
                    .get(10, TimeUnit.SECONDS);
            // ...
            session.subscribe("/topic/greetings", new GreetingFrameHandler());
            // ...
            session.send("/app/hello", "namalzi");
            printReceived();
            // ...
            template.convertAndSend("/topic/greetings", "A notice from the server");
            printReceived();

WsSci is a Tomcat class (it is not Spring's, so this post does not read its source). The StandardWebSocketUpgradeStrategy of section 2 takes the server's WebSocket container from the servlet context attribute jakarta.websocket.server.ServerContainer (lines 147–154), and registering WsSci is how I had Tomcat prepare that. As in example 3 the inbound and outbound channels were reduced to one thread each, and here Tomcat's request-processing threads were reduced to one as well. What the client receives is put on a queue by the client-side callback and printed by main (the lines that start with client:).

Tomcat started on a random port
stompWebSocketHandlerMapping: WebSocketHandlerMapping  /ws -> WebSocketHttpRequestHandler
handshake handler: DefaultHandshakeHandler, upgrade strategy: StandardWebSocketUpgradeStrategy

== 1) client connects: HTTP handshake, then STOMP CONNECT ==
  [http-nio-auto-1-exec-1 ] DispatcherServlet.doService  GET /ws
  [http-nio-auto-1-exec-1 ] beforeHandshake  GET /ws  Upgrade=websocket  Sec-WebSocket-Protocol=[v12.stomp,v11.stomp]
  [http-nio-auto-1-exec-1 ] afterHandshake   status=101  Upgrade=websocket  Sec-WebSocket-Protocol=v12.stomp
  [http-nio-auto-1-exec-1 ] afterConnectionEstablished  acceptedProtocol=v12.stomp  delegate=SubProtocolWebSocketHandler
  [http-nio-auto-1-exec-1 ] -> clientInboundChannel  type=CONNECT destination=null
  [clientInboundChannel-1 ] -> clientOutboundChannel  type=CONNECT_ACK destination=null
  [clientOutboundChannel-1] clientOutboundChannel hands it to SubProtocolWebSocketHandler
  client: connected = true

== 2) client -> server: SUBSCRIBE /topic/greetings ==
  [http-nio-auto-1-exec-1 ] -> clientInboundChannel  type=SUBSCRIBE destination=/topic/greetings

== 3) client -> server: SEND /app/hello ==
  [http-nio-auto-1-exec-1 ] -> clientInboundChannel  type=MESSAGE destination=/app/hello
  [clientInboundChannel-1 ] GreetingController.greet("namalzi")
  [clientInboundChannel-1 ] -> brokerChannel  type=MESSAGE destination=/topic/greetings
  [clientInboundChannel-1 ] -> clientOutboundChannel  type=MESSAGE destination=/topic/greetings
  [clientOutboundChannel-1] clientOutboundChannel hands it to SubProtocolWebSocketHandler
  client: received MESSAGE  destination=/topic/greetings subscription=0 payload=Hello, namalzi!

== 4) server code sends with SimpMessagingTemplate.convertAndSend ==
  template channel == brokerChannel ? true
  [main                   ] -> brokerChannel  type=MESSAGE destination=/topic/greetings
  [main                   ] -> clientOutboundChannel  type=MESSAGE destination=/topic/greetings
  [clientOutboundChannel-1] clientOutboundChannel hands it to SubProtocolWebSocketHandler
  client: received MESSAGE  destination=/topic/greetings subscription=0 payload=A notice from the server

== 5) client disconnects ==
  server: afterConnectionClosed  code=1000

Tomcat stopped. context active = false

I ran it seven times and got the same output, character for character, all seven times. In 5) the tracing is switched off, because I did not pin down the order of the lines printed while the connection closes. http-nio-auto-1-exec-1 is the name of the Tomcat thread that processes requests.

Here is how the output lines up with the explanations earlier in the post.

The frame text and the events such as SessionConnectEvent that example 3 showed are not printed in this example. Conversely, the handshake and the server's thread are visible only here.

Easy to get wrong

What this post left out

A lot was left out for the sake of the main thread. Only names and locations are given here. The SockJS transports and session management (the sockjs package of spring-websocket). The TCP connections and the "system session" of the external broker relay (StompBrokerRelayMessageHandler). Heartbeats (in the simple broker, from HeartbeatTask, line 508). The user registry SimpUserRegistry and broadcasting user destinations across several servers. OrderedMessageChannelDecorator, which preserves message order, and ConcurrentWebSocketSessionDecorator, which cuts off slow clients (line 48). Handling exceptions with @MessageExceptionHandler (lines 585–609). websocket-scoped beans (lines 145–150). The selector header that puts a SpEL condition on a subscription (from DefaultSubscriptionRegistry line 168). The internals of WebSocketStompClient, the Java STOMP client that example 4 only used as its client (line 75). And @EnableWebSocket, which registers a bare WebSocketHandler without STOMP, as well as reactive WebSocket on the WebFlux side, were not covered.

Recap

Concept One-line summary
Message a payload and an unmodifiable header map. One STOMP frame becomes one Message
MessageChannel / MessageHandler the slot and the receiver. Parts never call each other; they are connected only by channels
ExecutorSubscribableChannel gives the message to every subscribed handler — on another thread if it has an Executor
ChannelInterceptor changes the message or blocks it with null in preSend. Unlike a handshake interceptor, it applies to every message
Handshake one HTTP GET. WebSocketHttpRequestHandler → HandshakeHandler → the server's upgrade
SubProtocolWebSocketHandler both a WebSocketHandler and a MessageHandler. The border between WebSocket and the channels
StompSubProtocolHandler frame ↔ Message. On the way in it writes the session id and user into the headers
clientInboundChannel everything a client sent. Three handlers receive it and split it by prefix
SimpAnnotationMethodMessageHandler routes /app/.. to @MessageMapping methods. The return value goes to brokerChannel
brokerChannel the road from the server side to the broker. No Executor by default
SimpleBrokerMessageHandler remembers subscriptions and sends out one message per subscription
clientOutboundChannel everything going out to clients. SubProtocolWebSocketHandler subscribes to it
SimpMessagingTemplate for server code that sends directly. It enters at brokerChannel
@EnableWebSocketMessageBroker one @Import brings in three layers of configuration classes that create all the beans above

What to look at next

With this post, Phase 5 (web) on the map in Part 1 is finished. We started from the foundation in Part 20 and came through servlet MVC, WebFlux and now messaging.

In example 3 of this post we ran the flow with a fake session, without starting a server. The module that properly helps with running pieces of Spring without a server is spring-test. How a container is reused across tests instead of being started for each one, where JUnit and Spring meet, and MockMvc, which runs MVC without a server, are all there. That continues in Part 26: spring-test — SpringExtension, TestContext, MockMvc.

More posts