Scalable Enterprise Real‑Time Push with Spring Boot, WebFlux, Kafka & Redis
This guide walks through why traditional polling or WebSocket solutions quickly break at scale, explains the Server‑Sent Events protocol, and presents a production‑grade architecture that combines Spring Boot 3, WebFlux, Kafka, Redis, and Kubernetes to deliver reliable, ordered, and observable one‑way push notifications for millions of concurrent users.
The article starts by listing common real‑time push scenarios in e‑commerce (order status, payment results, workflow updates, AI streaming, etc.) and shows that naive implementations—client polling every few seconds, a single‑node ConcurrentHashMap<String, SseEmitter> store, or a plain WebSocket deployment—work only under low load. When traffic grows, they suffer from excessive empty requests, connection‑routing problems, high memory usage, and complex gateway integration.
Why SSE over Polling and WebSocket
Polling wastes bandwidth with empty requests and forces every tier (DNS, LB, gateway, auth, app) to handle unnecessary traffic. WebSocket provides full duplex communication but adds protocol overhead, complex authentication, binary framing, and higher resource consumption for primarily one‑way notification use cases. SSE, built on HTTP, offers built‑in browser support via EventSource, automatic reconnection with Last‑Event‑ID, and a lightweight server‑side model that fits single‑direction push.
SSE Protocol Essentials
SSE responses use Content‑Type: text/event‑stream, Cache‑Control: no‑cache, and Connection: keep‑alive. Each event consists of fields such as id, event, retry, and data, separated by a blank line. The id enables the browser to send Last‑Event‑ID on reconnection, allowing the server to replay missed events.
id: 101
event: order-status
retry: 3000
data: {"orderId":"O202608180001","status":"PAID"}
id: 102
event: order-status
data: {"orderId":"O202608180001","status":"SHIPPED"}Enterprise Architecture Overview
The recommended stack consists of:
Spring Boot 3 with WebFlux (reactive Netty) as the push service.
Kafka as the durable event bus, keyed by userId to guarantee per‑user ordering.
Redis ZSET for a short‑term replay window (default 10 minutes) that stores serialized PushEvent objects.
In‑memory session registry (
ConcurrentHashMap<String, Map<String, SseSession>>) for fast lookup of active connections.
Gateway/Ingress (Nginx, Spring Cloud Gateway) with buffering disabled and long timeouts.
Micrometer + Prometheus for observability.
Connection Lifecycle and Session Management
When a client calls /api/sse/subscribe, the service checks global and per‑user connection limits ( maxGlobalConnections, maxConnectionsPerUser). If allowed, it creates a SseSession containing a unique sessionId, the userId, the instance ID, subscribed topics, a Sinks.Many<ServerSentEvent<String>> sink, and timestamps. The session is registered in InMemorySessionRegistry and the user‑instance mapping is stored in Redis.
public Flux<ServerSentEvent<String>> subscribe(String userId, Set<String> topics, String lastEventId) {
// limit checks omitted for brevity
Sinks.Many<ServerSentEvent<String>> sink = Sinks.many().multicast().onBackpressureBuffer();
String sessionId = UUID.randomUUID().toString();
SseSession session = new SseSession(sessionId, userId, instanceId, topics, sink, Instant.now());
sessionRegistry.register(session);
routeRegistry.register(userId, instanceId, sseProperties.getRouteTtl());
// replay, heartbeat and cleanup omitted for brevity
return Flux.concat(initFlux, replayFlux, sink.asFlux().mergeWith(heartbeatFlux))
.doFinally(sig -> {
sessionRegistry.unregister(userId, sessionId);
routeRegistry.unregister(userId, instanceId);
});
}The publish method iterates over all sessions for the target user, filters by topic, and attempts to emit the event via the sink. If the sink returns FAIL_TERMINATED, the session is removed to avoid memory leaks.
public boolean publish(PushEvent event) {
Map<String, SseSession> sessions = sessionStore.get(event.getUserId());
if (sessions == null || sessions.isEmpty()) return false;
ServerSentEvent<String> sseEvent = ServerSentEvent.builder()
.id(event.getEventId())
.event(event.getEventType())
.data(event.getPayload())
.build();
boolean delivered = false;
for (SseSession session : sessions.values()) {
if (!session.getTopics().isEmpty() && !session.getTopics().contains(event.getTopic())) continue;
EmitResult result = session.getSink().tryEmitNext(sseEvent);
if (result.isSuccess()) {
session.touch();
delivered = true;
} else if (result == EmitResult.FAIL_TERMINATED) {
unregister(session.getUserId(), session.getSessionId());
}
}
return delivered;
}Replay Store (Redis)
Each event is appended to a Redis sorted set keyed by sse:replay:{userId}. The score is derived from the numeric part of the eventId. The set expires after the configured replay window, limiting memory usage.
public void append(PushEvent event) {
String key = KEY_PREFIX + event.getUserId();
redisTemplate.opsForZSet().add(key, objectMapper.writeValueAsString(event), score(event.getEventId()));
redisTemplate.expire(key, replayWindow);
}
public List<PushEvent> findAfter(String userId, String lastEventId, int limit) {
String key = KEY_PREFIX + userId;
double min = (lastEventId == null || lastEventId.isBlank()) ? Double.NEGATIVE_INFINITY : score(lastEventId);
Set<String> values = redisTemplate.opsForZSet().rangeByScore(key, min + 1, Double.POSITIVE_INFINITY, 0, limit);
// deserialize omitted for brevity
return result;
}Kafka Integration
Business services publish domain events to Kafka using KafkaPushEventPublisher. The key is userId, guaranteeing that all events for a user land in the same partition and retain order without extra sorting.
public void publishOrderStatusChanged(String userId, String orderId, String status, String traceId) {
PushEvent event = new PushEvent();
event.setEventId("evt-" + System.currentTimeMillis());
event.setUserId(userId);
event.setEventType("order-status");
event.setTopic("order");
event.setTraceId(traceId == null ? UUID.randomUUID().toString() : traceId);
event.setOccurredAt(Instant.now());
event.setPayload(String.format("{\"orderId\":\"%s\",\"status\":\"%s\"}", orderId, status));
kafkaTemplate.send(topic, userId, event);
}The consumer ( PushEventConsumer) uses manual immediate ack. It first stores the event in the replay store, then forwards it to the session registry. Only after successful delivery (or graceful failure) does it acknowledge the Kafka offset, ensuring at‑least‑once delivery without losing events.
@KafkaListener(topics = "${sse.topic}", containerFactory = "kafkaListenerContainerFactory")
public void onMessage(ConsumerRecord<String, PushEvent> record, Acknowledgment ack) {
PushEvent event = record.value();
try {
boolean delivered = sessionApplicationService.deliver(event);
if (!delivered) {
log.debug("No active session for userId={}, eventId={}", event.getUserId(), event.getEventId());
}
ack.acknowledge();
} catch (Exception ex) {
log.error("Consume push event failed, eventId={}", event.getEventId(), ex);
throw ex;
}
}Reliability and Compensation
The system defines four success layers: business state persisted, event written to Kafka, event stored in Redis replay window, and client actually receives the message. If a client disconnects, the next EventSource request includes Last‑Event‑ID; the server replays missed events from Redis. For longer‑term offline delivery, the article recommends an outbox table or a dedicated message archive.
Idempotency
All critical steps use eventId as the deduplication key: the outbox writer, the Kafka consumer, and the client‑side de‑duplication logic.
Scaling Considerations
Limit per‑user connections (default 3) and global connections (default 200 000) to protect the file‑descriptor pool.
Use WebFlux + Netty for non‑blocking I/O.
Heartbeat interval (default 15 s) must be shorter than any upstream idle timeout.
Kafka partition count should balance write throughput and consumer parallelism; user‑keyed partitioning avoids cross‑user ordering issues but may create hot partitions for very active users.
Monitor metrics such as sse_active_connections, sse_push_events_total, kafka_consumer_lag, and reconnection rates.
Gateway / Nginx Configuration
To keep the SSE stream alive, proxy buffering must be disabled and timeouts extended:
location /api/sse/ {
proxy_pass http://sse-push-service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}Security
The subscription endpoint requires authentication; the server extracts the user ID from the security principal instead of trusting a client‑supplied userId. Role‑based or tenant‑based filtering can be added by extending the topic subscription logic.
Observability
A simple Micrometer gauge exposes the total active connections:
@Component
public class SseMetricsBinder {
private final MeterRegistry meterRegistry;
private final SessionRegistry sessionRegistry;
@PostConstruct
public void bind() {
Gauge.builder("sse_active_connections", sessionRegistry, SessionRegistry::totalConnections)
.description("Current active SSE connections")
.register(meterRegistry);
}
}Additional counters for pushes, failures, replay hits, and Kafka lag should be added for production monitoring and alerting.
Testing and Migration Path
The article outlines a staged evolution: start with a simple SseEmitter demo, then move to the full WebFlux + Kafka + Redis stack when traffic grows. Load‑testing should cover connection ramp‑up, peak event rates, reconnection storms, and failure injection (pod restarts, Redis hiccups, Kafka lag).
When Not to Use SSE
SSE is unsuitable for bidirectional high‑frequency communication, binary payloads, or clients without native EventSource support. In those cases WebSocket, gRPC streaming, or MQTT are better choices.
Final Takeaway
Successful enterprise SSE deployment hinges on six pillars: connection governance, distributed routing, reliable messaging, replay compensation, gateway compatibility, and end‑to‑end observability. When these are addressed, SSE becomes a lightweight yet robust solution for massive one‑way real‑time push workloads.
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.
Cloud Architecture
Focuses on cloud‑native and distributed architecture engineering, sharing practical solutions and lessons learned. Covers microservice governance, Kubernetes, observability, and stability engineering to help your systems run stable, fast, and cost‑effectively.
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.
