Skip to main content

Chapter 5: WebSockets and Real-Time Communication

Chapter Objectives​

  • Understand the WebSocket protocol
  • Configure Spring WebSocket with STOMP
  • Implement a real-time messaging system
  • Manage WebSocket authentication
  • Create chat and notification applications

1. Introduction to WebSockets​

1.1 HTTP vs WebSocket​

HTTP (Request-Response):
Client ──GET──> Server
Client <──200── Server
Client ──GET──> Server
Client <──200── Server
(Connection closed after each exchange)

WebSocket (Bidirectional):
Client ──Handshake──> Server
<──Upgrade────
←───────────→ (Persistent connection)
←───────────→ (Bidirectional messages)

1.2 When to Use WebSocket​

Use CaseSolution
Real-time chatWebSocket
Push notificationsWebSocket
Multiplayer gamesWebSocket
Real-time tradingWebSocket
Live dashboardsWebSocket
Frequent data (polling)WebSocket
Standard REST APIHTTP

1.3 STOMP Protocol​

STOMP (Simple Text Oriented Messaging Protocol) provides an abstraction layer over WebSocket:

STOMP Frame:
COMMAND
header1:value1
header2:value2

Body^@

Main commands:

  • CONNECT - Establish connection
  • SUBSCRIBE - Subscribe to a destination
  • SEND - Send a message
  • MESSAGE - Receive a message
  • DISCONNECT - Close connection

2. Spring WebSocket Configuration​

2.1 Maven Dependencies​

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

<!-- For SockJS fallback -->
<dependency>
<groupId>org.webjars</groupId>
<artifactId>sockjs-client</artifactId>
<version>1.5.1</version>
</dependency>

<!-- For STOMP -->
<dependency>
<groupId>org.webjars</groupId>
<artifactId>stomp-websocket</artifactId>
<version>2.3.4</version>
</dependency>
</dependencies>

2.2 WebSocket Configuration​

package com.example.websocket.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// Prefix for destinations the server sends to clients
// /topic = broadcast to all subscribers
// /queue = private message to a user
registry.enableSimpleBroker("/topic", "/queue");

// Prefix for destinations clients send to the server
registry.setApplicationDestinationPrefixes("/app");

// Prefix for private messages to a specific user
registry.setUserDestinationPrefix("/user");
}

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// WebSocket endpoint with SockJS fallback
registry.addEndpoint("/ws")
.setAllowedOrigins("http://localhost:3000", "http://localhost:8080")
.withSockJS();
}
}

3. WebSocket Controller​

3.1 Message DTOs​

package com.example.websocket.dto;

public class ChatMessage {

private String sender;
private String content;
private String timestamp;
private MessageType type;

public enum MessageType {
CHAT, JOIN, LEAVE
}

public ChatMessage() {}

public ChatMessage(String sender, String content, MessageType type) {
this.sender = sender;
this.content = content;
this.type = type;
this.timestamp = java.time.LocalDateTime.now().toString();
}

// Getters and Setters
public String getSender() { return sender; }
public void setSender(String sender) { this.sender = sender; }

public String getContent() { return content; }
public void setContent(String content) { this.content = content; }

public String getTimestamp() { return timestamp; }
public void setTimestamp(String timestamp) { this.timestamp = timestamp; }

public MessageType getType() { return type; }
public void setType(MessageType type) { this.type = type; }
}

3.2 Controller​

package com.example.websocket.controller;

import com.example.websocket.dto.ChatMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.stereotype.Controller;

@Controller
public class ChatController {

private static final Logger log = LoggerFactory.getLogger(ChatController.class);

// Client sends to /app/chat.sendMessage
// Server broadcasts to /topic/public
@MessageMapping("/chat.sendMessage")
@SendTo("/topic/public")
public ChatMessage sendMessage(@Payload ChatMessage message) {
log.info("Message received: {} from {}", message.getContent(), message.getSender());
return message;
}

// Handle user joining
@MessageMapping("/chat.addUser")
@SendTo("/topic/public")
public ChatMessage addUser(@Payload ChatMessage message,
SimpMessageHeaderAccessor headerAccessor) {

// Store username in WebSocket session
headerAccessor.getSessionAttributes().put("username", message.getSender());

log.info("User joined: {}", message.getSender());

message.setType(ChatMessage.MessageType.JOIN);
return message;
}
}

3.3 Sending Messages from a Service​

package com.example.websocket.service;

import com.example.websocket.dto.ChatMessage;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;

@Service
public class NotificationService {

private final SimpMessagingTemplate messagingTemplate;

public NotificationService(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}

// Broadcast to all subscribers of /topic/notifications
public void sendBroadcastNotification(String message) {
ChatMessage notification = new ChatMessage(
"System",
message,
ChatMessage.MessageType.CHAT
);

messagingTemplate.convertAndSend("/topic/notifications", notification);
}

// Private message to a specific user
public void sendPrivateMessage(String username, String message) {
ChatMessage privateMessage = new ChatMessage(
"System",
message,
ChatMessage.MessageType.CHAT
);

// Send to /user/{username}/queue/private
messagingTemplate.convertAndSendToUser(
username,
"/queue/private",
privateMessage
);
}

// Send to a specific destination
public void sendToRoom(String roomId, ChatMessage message) {
messagingTemplate.convertAndSend("/topic/room/" + roomId, message);
}
}

4. Event Listeners​

4.1 Managing Connections/Disconnections​

package com.example.websocket.listener;

import com.example.websocket.dto.ChatMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionConnectedEvent;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;

@Component
public class WebSocketEventListener {

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

private final SimpMessageSendingOperations messagingTemplate;

public WebSocketEventListener(SimpMessageSendingOperations messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}

@EventListener
public void handleWebSocketConnectListener(SessionConnectedEvent event) {
log.info("New WebSocket connection established");
}

@EventListener
public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());

String username = (String) headerAccessor.getSessionAttributes().get("username");

if (username != null) {
log.info("User disconnected: {}", username);

ChatMessage leaveMessage = new ChatMessage(
username,
username + " has left the chat",
ChatMessage.MessageType.LEAVE
);

messagingTemplate.convertAndSend("/topic/public", leaveMessage);
}
}
}

5. JavaScript Client​

5.1 HTML Template​

<!DOCTYPE html>
<html>
<head>
<title>WebSocket Chat</title>
<script src="/webjars/sockjs-client/sockjs.min.js"></script>
<script src="/webjars/stomp-websocket/stomp.min.js"></script>
<style>
.chat-container { max-width: 600px; margin: 0 auto; }
.messages { height: 400px; overflow-y: auto; border: 1px solid #ccc; padding: 10px; }
.message { padding: 5px; margin: 5px 0; }
.join-message { color: green; }
.leave-message { color: red; }
input[type="text"] { width: 70%; padding: 10px; }
button { padding: 10px 20px; }
</style>
</head>
<body>
<div class="chat-container">
<div id="login-page">
<input type="text" id="username" placeholder="Enter your name">
<button onclick="connect()">Join Chat</button>
</div>

<div id="chat-page" style="display: none;">
<div class="messages" id="messages"></div>
<input type="text" id="message" placeholder="Type a message...">
<button onclick="sendMessage()">Send</button>
</div>
</div>

<script>
let stompClient = null;
let username = null;

function connect() {
username = document.getElementById('username').value.trim();
if (!username) return;

// SockJS connection
const socket = new SockJS('/ws');
stompClient = Stomp.over(socket);

stompClient.connect({}, onConnected, onError);
}

function onConnected() {
document.getElementById('login-page').style.display = 'none';
document.getElementById('chat-page').style.display = 'block';

// Subscribe to public topic
stompClient.subscribe('/topic/public', onMessageReceived);

// Subscribe to private messages
stompClient.subscribe('/user/queue/private', onPrivateMessage);

// Announce arrival
stompClient.send('/app/chat.addUser', {}, JSON.stringify({
sender: username,
type: 'JOIN'
}));
}

function onError(error) {
console.error('WebSocket error:', error);
alert('Could not connect to WebSocket server');
}

function sendMessage() {
const messageInput = document.getElementById('message');
const content = messageInput.value.trim();

if (content && stompClient) {
stompClient.send('/app/chat.sendMessage', {}, JSON.stringify({
sender: username,
content: content,
type: 'CHAT'
}));

messageInput.value = '';
}
}

function onMessageReceived(payload) {
const message = JSON.parse(payload.body);
displayMessage(message);
}

function onPrivateMessage(payload) {
const message = JSON.parse(payload.body);
message.content = '[Private] ' + message.content;
displayMessage(message);
}

function displayMessage(message) {
const messagesDiv = document.getElementById('messages');
const messageElement = document.createElement('div');
messageElement.className = 'message';

if (message.type === 'JOIN') {
messageElement.className += ' join-message';
messageElement.textContent = message.sender + ' joined the chat';
} else if (message.type === 'LEAVE') {
messageElement.className += ' leave-message';
messageElement.textContent = message.sender + ' left the chat';
} else {
messageElement.textContent = message.sender + ': ' + message.content;
}

messagesDiv.appendChild(messageElement);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}

// Send with Enter
document.getElementById('message').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
sendMessage();
}
});
</script>
</body>
</html>

6. WebSocket Authentication​

6.1 Channel Interceptor​

package com.example.websocket.config;

import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;

@Component
public class AuthChannelInterceptor implements ChannelInterceptor {

private final JwtService jwtService;

public AuthChannelInterceptor(JwtService jwtService) {
this.jwtService = jwtService;
}

@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor
.getAccessor(message, StompHeaderAccessor.class);

if (StompCommand.CONNECT.equals(accessor.getCommand())) {
// Extract token from header
String token = accessor.getFirstNativeHeader("Authorization");

if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);

if (jwtService.validateToken(token)) {
String username = jwtService.extractUsername(token);

UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
username, null, Collections.emptyList()
);

accessor.setUser(auth);
}
}
}

return message;
}
}

6.2 Register the Interceptor​

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

private final AuthChannelInterceptor authInterceptor;

@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(authInterceptor);
}

// ... other configurations
}

7. Rooms and Channels​

7.1 Controller with Rooms​

@Controller
public class RoomController {

@MessageMapping("/room/{roomId}/send")
@SendTo("/topic/room/{roomId}")
public ChatMessage sendToRoom(
@DestinationVariable String roomId,
@Payload ChatMessage message) {

log.info("Message to room {}: {}", roomId, message.getContent());
return message;
}

@MessageMapping("/room/{roomId}/join")
@SendTo("/topic/room/{roomId}")
public ChatMessage joinRoom(
@DestinationVariable String roomId,
@Payload ChatMessage message,
SimpMessageHeaderAccessor headerAccessor) {

// Store room in session
headerAccessor.getSessionAttributes().put("currentRoom", roomId);

message.setType(ChatMessage.MessageType.JOIN);
message.setContent(message.getSender() + " joined room " + roomId);

return message;
}
}

7.2 JavaScript Client - Rooms​

function joinRoom(roomId) {
// Unsubscribe from the old room
if (currentRoomSubscription) {
currentRoomSubscription.unsubscribe();
}

// Subscribe to the new room
currentRoomSubscription = stompClient.subscribe(
'/topic/room/' + roomId,
onRoomMessage
);

// Announce arrival
stompClient.send('/app/room/' + roomId + '/join', {}, JSON.stringify({
sender: username,
type: 'JOIN'
}));

currentRoom = roomId;
}

function sendToRoom(content) {
stompClient.send('/app/room/' + currentRoom + '/send', {}, JSON.stringify({
sender: username,
content: content,
type: 'CHAT'
}));
}

8. Summary​

Architecture​

Client (Browser)
|
| SockJS / WebSocket
|
Spring WebSocket
|
├── STOMP Protocol
|
├── Message Broker (/topic, /queue)
|
└── @MessageMapping Controllers

Destination Prefixes​

PrefixUsage
/appClient to server
/topicBroadcast (1-to-many)
/queuePoint-to-point (1-to-1)
/userPrivate messages

Best Practices​

  1. Use SockJS for compatibility
  2. Implement authentication
  3. Handle disconnections properly
  4. Limit message size
  5. Monitor active connections
  6. Use rooms for scaling

Proceed to Practical Work

Back to Module 14