Stop Polling: 4 Real-Time Push Patterns in Spring Boot 3.5
This article demonstrates four Spring Boot patterns to replace inefficient polling with real-time server push: STOMP over WebSocket for bidirectional updates, user-specific private channels, SSE with WebFlux for unidirectional streams, and Debezium CDC for database-level change capture — each with working code and configuration details.
1. Introduction: The Polling Problem
Front-end dashboards often start with simple polling — setInterval(() => { fetch('/api/orders/status') }, 5000) — to show real-time order status. As more components are added (inventory every 10 s, shipments every 3 s), a single open dashboard can generate 2,000 requests per minute, 80% returning empty responses. Adding Redis caching helps the database but does not solve network saturation or the inherent latency of polling.
2. Practical Patterns
2.1 STOMP Bidirectional Real-Time Updates
Spring Boot's STOMP-over-WebSocket support lets the server push updates the moment a database write commits, instead of waiting for the next client request.
Configure the message broker and STOMP endpoint:
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS();
}
}Publish events only after transaction commit to guarantee consistency:
private final ApplicationEventMulticaster events;
@Transactional
public void updateStatus(Long orderId, OrderStatus newStatus) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.setStatus(newStatus);
orderRepository.save(order);
events.publishEvent(new OrderStatusChanged(orderId, newStatus)); // in-process only
}
@Component
public class OrderStatusBroadcaster {
private final SimpMessagingTemplate messaging;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onStatusChanged(OrderStatusChanged event) {
messaging.convertAndSend(
"/topic/orders/" + event.orderId() + "/status",
new OrderStatusUpdate(event.orderId(), event.newStatus()));
}
}2.2 Creating Single-User Private Channels
Broadcasting to /topic/orders/42/status sends updates to all subscribers — acceptable for public order status but wrong for private notifications. convertAndSendToUser targets a single authenticated principal at /user/{username}/queue/notifications.
@Component
public class NotificationBroadcaster {
private final SimpMessagingTemplate messaging;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onPaymentFailed(PaymentFailedEvent event) {
messaging.convertAndSendToUser(
event.username(),
"/queue/notifications",
new PaymentFailedNotification(event.orderId(), event.reason()));
}
}Client-side subscription:
stompClient.subscribe('/user/queue/notifications', (message) => {
const notification = JSON.parse(message.body);
showToast(notification.reason);
});Spring automatically resolves /user/{username}/queue/notifications to the principal's current session.
2.3 SSE Real-Time Unidirectional Messages
Server-Sent Events keep a plain HTTP connection open; the browser auto-reconnects on disconnect. Spring WebFlux supports SSE natively via Flux<ServerSentEvent<T>>.
// WebFlux true non-blocking: one event-loop thread handles many streams
@RestController
@RequestMapping("/api/prices")
public class PriceController {
private final PriceService priceService;
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<BigDecimal>> streamPrices() {
return priceService.priceStream()
.map(price -> ServerSentEvent.<BigDecimal>builder()
.id(String.valueOf(System.currentTimeMillis()))
.event("price-update")
.data(price)
.build());
}
}Add a heartbeat to prevent proxies/load balancers from closing idle connections:
public Flux<ServerSentEvent<BigDecimal>> streamPrices() {
Flux<ServerSentEvent<BigDecimal>> prices = priceService.priceStream()
.map(p -> ServerSentEvent.<BigDecimal>builder().data(p).event("price-update").build());
Flux<ServerSentEvent<BigDecimal>> heartbeat = Flux.interval(Duration.ofSeconds(20))
.map(t -> ServerSentEvent.<BigDecimal>builder().comment("keep-alive").build());
return Flux.merge(prices, heartbeat);
}2.4 Debezium CDC: Database-Level Change Capture
Multiple systems (audit, search indexer, live dashboard) must react to the same database changes. Application-layer events only fire when changes go through the Java service; SQL hotfixes, migration scripts, or batch jobs bypass the service entirely, leaving downstream consumers unaware.
Debezium tails PostgreSQL's write-ahead log (WAL) and publishes every INSERT, UPDATE, DELETE to Kafka — regardless of origin. No application code in the write path. Configure the connector via Kafka Connect REST API:
{
"name": "orders-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"topic.prefix": "ordersdb",
"table.include.list": "public.orders",
"plugin.name": "pgoutput",
"slot.name": "debezium_orders"
}
} topic.prefixdetermines the Kafka topic name: ordersdb.public.orders. Each message contains before/after row states and an op field (c=create, u=update, d=delete, r=snapshot read). Consume in Spring Boot and push to the WebSocket broker:
@Service
public class OrderCdcConsumer {
private final ObjectMapper mapper;
private final SimpMessagingTemplate messaging;
@KafkaListener(topics = "ordersdb.public.orders", groupId = "live-dashboard")
public void onOrderChange(@Payload(required = false) String json) throws Exception {
if (json == null) return; // DELETE produces tombstone null, skip
JsonNode payload = mapper.readTree(json).path("payload");
String op = payload.path("op").asText();
if ("d".equals(op)) return;
JsonNode after = payload.path("after");
Long orderId = after.get("id").asLong();
String status = after.get("status").asText();
messaging.convertAndSend(
"/topic/orders/" + orderId + "/status",
new OrderStatusUpdate(orderId, OrderStatus.valueOf(status)));
}
}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.
