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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
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.
