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.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Stop Polling: 4 Real-Time Push Patterns in Spring Boot 3.5

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.prefix

determines 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)));
  }
}
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Real-timeSpring BootWebSocketWebFluxCDCSSEDebeziumSTOMP
Spring Full-Stack Practical Cases
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.