Build a Millisecond‑Level Real‑Time Online System with Spring Boot, WebSocket, and Redis
This article demonstrates how to create a millisecond‑level real‑time online user tracking system using Spring Boot 3.5, WebSocket with STOMP, and Redis pub/sub, covering environment setup, Maven dependencies, server‑side configuration, presence services, event listeners, and a simple front‑end page.
Introduction
Real‑time online status is a core requirement for many applications, from collaborative tools to social platforms. Traditional polling suffers from high latency and poor performance, while a WebSocket‑based push combined with Redis’s high‑throughput pub/sub can achieve millisecond‑level synchronization.
Environment
Spring Boot version 3.5.0 is used for the implementation.
1. Maven Dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>2. WebSocket Configuration
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/handshake")
.setHandshakeHandler(new DefaultHandshakeHandler() {
@Override
protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler,
Map<String, Object> attributes) {
String user = request.getURI().getQuery();
if (user != null && user.startsWith("user=")) {
String username = user.substring(5);
return new StompPrincipal(username);
}
return null;
}
})
.withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.setApplicationDestinationPrefixes("/app");
config.enableSimpleBroker("/topic", "/queue");
}
private static class StompPrincipal implements Principal {
private final String name;
public StompPrincipal(String name) { this.name = name; }
@Override public String getName() { return this.name; }
}
}3. Redis Pub/Sub Configuration
@Configuration
public class RedisConfig {
@Bean
RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory,
MessageListenerAdapter listenerAdapter) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(listenerAdapter, new ChannelTopic("presence-events"));
return container;
}
@Bean
MessageListenerAdapter listenerAdapter(PresenceSubscriber subscriber) {
return new MessageListenerAdapter(subscriber, "onMessage");
}
@Bean
ChannelTopic presenceTopic() { return new ChannelTopic("presence-events"); }
}4. Presence Service (Online State Management)
@Service
public class PresenceService {
private static final String ONLINE_SET = "presence:online";
private static final String LAST_SEEN = "presence:lastSeen:";
private static final String SESSIONS = "presence:sessions:";
private final StringRedisTemplate stringRedisTemplate;
private final PresencePublisher publisher;
private final PresenceBroadcast broadcast;
public PresenceService(StringRedisTemplate stringRedisTemplate, PresencePublisher publisher,
PresenceBroadcast broadcast) {
this.stringRedisTemplate = stringRedisTemplate;
this.publisher = publisher;
this.broadcast = broadcast;
}
public void applyPresenceUpdate(PresenceEvent event) {
if (event.online()) {
stringRedisTemplate.opsForSet().add(ONLINE_SET, event.userId());
} else {
stringRedisTemplate.opsForSet().remove(ONLINE_SET, event.userId());
stringRedisTemplate.opsForValue().set(LAST_SEEN + event.userId(), String.valueOf(event.timestamp()));
}
this.broadcast.pushUpdate(event);
}
public void onConnect(String userId) {
long count = stringRedisTemplate.opsForValue().increment(SESSIONS + userId);
if (count == 1) { publishPresence(userId, true); }
}
public void onDisconnect(String userId) {
long count = stringRedisTemplate.opsForValue().decrement(SESSIONS + userId);
if (count <= 0) {
publishPresence(userId, false);
stringRedisTemplate.delete(SESSIONS + userId);
}
}
private void publishPresence(String userId, boolean online) {
PresenceEvent event = new PresenceEvent(userId, online, System.currentTimeMillis(), UUID.randomUUID().toString());
publisher.publish(event);
this.broadcast.pushUpdate(event);
}
}5. Message Publisher and Broadcast
@Service
public class PresenceBroadcast {
private final SimpMessagingTemplate messaging;
public PresenceBroadcast(SimpMessagingTemplate messaging) { this.messaging = messaging; }
public void pushUpdate(PresenceEvent event) {
messaging.convertAndSend("/topic/presence", event);
}
}6. WebSocket Event Listeners
@Component
public class WebSocketConnectListener implements ApplicationListener<SessionConnectEvent> {
private final PresenceService presenceService;
public WebSocketConnectListener(PresenceService presenceService) { this.presenceService = presenceService; }
@Override
public void onApplicationEvent(SessionConnectEvent event) {
String userId = event.getUser().getName();
presenceService.onConnect(userId);
}
}
@Component
public class WebSocketDisconnectListener implements ApplicationListener<SessionDisconnectEvent> {
private final PresenceService presenceService;
public WebSocketDisconnectListener(PresenceService presenceService) { this.presenceService = presenceService; }
@Override
public void onApplicationEvent(SessionDisconnectEvent event) {
String userId = event.getUser().getName();
presenceService.onDisconnect(userId);
}
}7. Front‑End Page
<head>
<script>
let stompClient = null;
let name = null;
function connect() {
name = document.querySelector("#username").value;
const socket = new SockJS(`/handshake?user=${name}`);
stompClient = webstomp.over(socket, { debug: true });
stompClient.connect({ login: "pack" }, function(frame) {
if (frame.command === 'CONNECTED') {
addOnlineUser(name);
stompClient.subscribe('/topic/presence', data => {
const body = JSON.parse(data.body);
if (body.online) { addOnlineUser(body.userId); }
else { deleteUser(body.userId); }
});
}
});
}
function disconnect() {
if (stompClient) {
stompClient.disconnect(() => {
stompClient = null;
if (name) deleteUser(name);
});
}
}
function addOnlineUser(name) {
if (document.querySelector(`li[data-name="${name}"]`)) return;
const li = document.createElement('li');
li.setAttribute('data-name', name);
li.textContent = name;
document.querySelector('.users').appendChild(li);
}
function deleteUser(name) {
const li = document.querySelector(`li[data-name="${name}"]`);
if (li) li.remove();
}
</script>
</head>
<body>
<h1>Online User Statistics</h1>
<div class="input-group">
<label for="username">Login User</label>
<input type="text" id="username" placeholder="Enter username"/>
</div>
<div class="button-group">
<button id="connectBtn" onclick="connect()">Connect</button>
<button id="disconnectBtn" onclick="disconnect()">Disconnect</button>
</div>
<span class="online-title">Current Online Users</span>
<div class="online-list">
<ul class="users"></ul>
</div>
</body>8. Result
The page shows a list that updates instantly as users connect or disconnect, demonstrating millisecond‑level online presence tracking.
Spring Full-Stack Practical Cases
Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
