- Published on
Replacing Shared WebSocket Broadcasts with User-Specific STOMP Destinations
- Authors

- Name
- Maria
Replacing Shared WebSocket Broadcasts with User-Specific STOMP Destinations
A WebSocket connection is private, but a STOMP topic is not automatically private.
That distinction mattered in a real-time Spring Boot application where every connected browser subscribed to the same destinations. When the server published a conversation event or an update, the broker sent it to every matching subscriber. Each browser received data even when only one user needed it.
The first fix was not a faster loop or a compressed payload. I changed the destination model so the intended recipient was part of the routing decision before the server wrote a WebSocket frame.
This article explains that single-server improvement: moving from shared topics to user-specific destinations, preserving the few cases that genuinely required multiple subscribers, and verifying that an unsubscribed browser did not receive the application payload.
TL;DR
- A STOMP broker sends a topic message to every subscription that matches the destination.
- One authenticated WebSocket connection does not make a shared
/topic/...destination user-specific.- I replaced broad subscriptions with destinations resolved from a trusted user identity.
- The publisher selected the recipient; the browser did not choose an arbitrary target identity.
- Shared monitoring remained an explicit use case instead of the default for every event.
- Packet inspection confirmed that the server-to-browser application payload appeared for a matching subscription and did not appear when no client subscribed to the destination.
The Original Broadcast Model
The initial client flow was conceptually simple:
stompClient.subscribe('/topic/events', onEvent)
stompClient.subscribe('/topic/updates', onUpdate)
The server published to the same shared topics:
messagingTemplate.convertAndSend("/topic/events", payload);
With one user and one test browser, this appears correct. There is only one subscriber, so “broadcast to all subscribers” and “send to the intended user” produce the same visible result.
The flaw emerges as connections increase:
Event for User A
-> /topic/events
-> Browser A
-> Browser B
-> Browser C
The WebSocket server is not confused. The STOMP broker is applying the subscription model it was given. Spring's message-flow documentation describes this directly: the broker finds all matching subscribers and sends a message through the outbound channel to each of them.
If only User A should see the event, the destination cannot remain shared by default.
Why Filtering in the Browser Was the Wrong Boundary
One tempting workaround is to broadcast the payload and let each browser discard messages whose userId does not match its current user.
function onEvent(message) {
const event = JSON.parse(message.body)
if (event.userId !== currentUserId) return
render(event)
}
This can reduce incorrect rendering, but it does not reduce delivery. Every browser still receives and parses every matching event.
It also creates an unsafe boundary for sensitive data. Client-side filtering happens after the payload has crossed the network and reached a browser that did not need it. Authorization must be enforced before delivery, not treated as a rendering preference.
The better question was: what destination represents the recipient before the broker selects subscribers?
First Design: Put the User in the Destination
I changed the topic structure so each browser subscribed to destinations associated with its user identity.
The simplified shape was:
/topic/events/{userId}
/topic/updates/{userId}
The publisher selected the corresponding destination:
public void sendToUser(String userId, EventPayload payload) {
String destination = "/topic/events/" + userId;
messagingTemplate.convertAndSend(destination, payload);
}
And the intended browser subscribed to its own resolved path:
const destination = `/topic/events/${authenticatedUserId}`
stompClient.subscribe(destination, onEvent)
The flow became:
Event for User A
-> /topic/events/user-a
-> Browser A
Browser B subscribes to /topic/events/user-b
Browser C subscribes to /topic/events/user-c
This made the target visible in the message path and removed the need for every browser to receive every application event.
The Identity Must Come from a Trusted Boundary
Putting a user ID in a destination is a routing technique, not authentication.
A client must not gain access to another user's messages merely by changing:
/topic/events/user-a
to:
/topic/events/user-b
The server must authenticate the WebSocket session and authorize subscriptions or resolve the destination from the authenticated Principal. The exact mechanism depends on the broker configuration, security layer, and whether Spring's user-destination support is used.
For a current Spring application, I would prefer the built-in /user/ abstraction when its semantics match the product:
stompClient.subscribe('/user/queue/events', onEvent)
The server can target a user without exposing the physical session-specific destination:
messagingTemplate.convertAndSendToUser(
username,
"/queue/events",
payload
);
Spring's UserDestinationMessageHandler translates the generic /user/queue/events subscription into a destination unique to the session. On the sending side, one logical user destination can resolve to one or more sessions for that user.
That last point matters when the same account opens multiple tabs or devices. The expected policy must be explicit:
- deliver to every active session for the user;
- deliver only to one selected session;
- treat tabs as separate device sessions;
- replace the previous connection on a new login.
The custom user-ID topic in the original change made the routing problem visible. Spring user destinations provide a more framework-native form when authentication and multi-session behavior are properly configured.
Shared Delivery Still Had Legitimate Uses
Not every broadcast was a bug.
The application also had monitoring-style flows where an authorized supervisor could observe another user's real-time stream, and administrative events that multiple connected clients legitimately needed.
I separated those cases from ordinary user delivery:
| Event type | Destination policy |
|---|---|
| Private user event | User-specific destination |
| Personal status update | User-specific destination |
| Authorized monitoring | Explicit monitor subscription checked by the server |
| System-wide notice | Shared topic |
| Tenant-wide operational event | Tenant-scoped shared topic |
The key was to make broadcast opt-in. A new event should not become visible to every client simply because the developer chose a convenient shared topic.
For monitoring, knowing another user's identifier was not enough. The subscription needed an authorization decision tied to the session's role and tenant boundary.
Verify the Subscription, Not Just the Publish Call
A log line after convertAndSend proves that application code invoked the messaging template. It does not prove which browsers received the frame.
I tested the flow with separate browser connections and observed network traffic for two conditions.
Matching subscriber
- Browser A subscribed to a user-specific destination.
- A test sender produced an event for User A.
- The server accepted and processed the event.
- A server-to-browser WebSocket payload appeared on Browser A's connection.
- Browser A acknowledged the underlying TCP data as expected.
No matching subscriber
- The sender produced an event for a destination with no active subscriber.
- The server accepted the input event.
- No corresponding application payload was sent to a browser connection.
The test separated inbound and outbound evidence:
Test sender -> Application server input accepted
Application server -> Browser WebSocket delivery
Browser -> Application server TCP acknowledgment
Seeing traffic between the sender and server did not count as proof of browser delivery. I needed to observe the outbound WebSocket side separately.
Packet capture was useful for proving the presence or absence of the application payload in a controlled test. In production, encrypted WebSocket traffic and scale make application-level correlation IDs and metrics more practical.
A Test Matrix for User-Specific Destinations
The smallest useful test needs more than one user.
| Active subscriptions | Published target | Expected delivery |
|---|---|---|
| User A only | User A | A receives once |
| User A only | User B | No browser receives |
| Users A and B | User A | Only A receives |
| Users A and B | User B | Only B receives |
| Two sessions for User A | User A | Follow the defined multi-session policy |
| Authorized monitor observes A | User A | A and monitor receive according to policy |
| Unauthorized user guesses A's path | User A | Subscription is rejected or receives nothing |
| User reconnects | User | No stale duplicate subscription |
I would add tenant separation as soon as the same service hosts more than one tenant. A user ID alone may not be globally unique, and the same display name must never define a security boundary.
What Changed in the System
The structural change was small enough to describe in one line:
shared destination -> recipient-specific destination
Its effect crossed several layers:
- the publisher had to know the intended recipient;
- destination construction had to use one consistent rule;
- WebSocket subscriptions had to follow authenticated identity;
- authorization had to run before a subscription became active;
- monitoring and broadcast cases needed explicit destinations;
- logs needed destination and correlation information without exposing sensitive payloads.
I did not preserve an independently reproducible benchmark from the original system, so I do not attach a percentage or an asymptotic performance claim to this article. The verified behavioral result is narrower: a matching subscriber received the payload, while a destination with no subscriber produced no corresponding browser delivery in the controlled packet test.
That is still valuable. It changes the system from “send everything and hope clients ignore it” to “select the recipient before data leaves the server.”
The Multi-Server Boundary Comes Next
User-specific destinations solved the broad-delivery problem on one WebSocket server. They did not solve WebSocket session ownership across several servers.
If multiple application instances compete on one RabbitMQ queue, the instance that consumes an event may not own the target user's WebSocket session. The broker can report successful consumption while the browser receives nothing.
That second failure required a different topology. I describe it in When RabbitMQ Round-Robin Broke WebSocket Delivery Across Multiple Servers.
Keeping the two problems separate made the design easier to reason about:
- user-specific destinations decide who should receive the event;
- broker topology decides which server must observe it;
- the local session registry decides whether that server can write to the browser.
The original shared topic collapsed all three questions into a broadcast. Making each boundary explicit reduced unnecessary delivery and exposed the next scaling problem instead of hiding it.