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.

Cloud Architecture
Cloud Architecture
Cloud Architecture
Scalable Enterprise Real‑Time Push with Spring Boot, WebFlux, Kafka & Redis

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.

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.

RedisKafkaSpring BootWebFluxEnterprise ArchitectureReal-time PushSSE
Cloud Architecture
Written by

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.

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.