Server-Sent Events in Spring Boot: Redis Pub/Sub and slow clients

Real-time sync with SseEmitter: cookie auth for EventSource, fan-out across instances, a bounded write pool that drops hints, and when to consider WebFlux.

· Published · Updated · 8 min read · Versions: Spring Boot 3.5, Redis 7 · About the project →

Personal OS is a time-management app used on several devices at once: edit the schedule on a laptop and the phone should see it almost immediately. The backend is Spring Boot (Java 21), and the "real-time" part uses Server-Sent Events (SSE) with Spring MVC's SseEmitter, plus Redis Pub/Sub when running several instances.

SSE is quick to set up, but running it reliably takes attention to a few things the docs rarely mention. This post covers the design, and the first thing that breaks as connections grow. It isn't the database, and it isn't the connection pool.

Why SSE rather than WebSockets

The app only needs one direction: the server telling the client "something changed". Clients send data up through ordinary REST calls. For that, SSE has some advantages:

  • It's plain HTTP, so it passes through proxies and load balancers easily.
  • Browsers ship EventSource, which reconnects automatically when the connection drops.
  • On the Spring MVC side you just return an SseEmitter; no new stack required.

The principle: the stream carries hints, not data

This is the most important decision. Each SSE event only contains the name of the table that changed, for example changed: tasks. When the client gets one, it calls the sync API (REST) to pull the actual data. How a sync API like this resolves conflicts is covered in local-first PWA sync.

public record SyncEvent(String userId, String table) {}

It looks like an extra request, but in exchange:

  • Authentication, per-user scoping and conflict resolution live in one place: the sync API. The SSE stream doesn't need to know about any of it.
  • Losing an event is harmless. An event only means "go pull". Clients also pull periodically to reconcile, so a missed hint delays things by at most one cycle and never loses data.

That second point is what saves the whole design later on.

Authenticating when EventSource can't send headers

The browser's EventSource can't set headers (the EventSource() constructor only takes a withCredentials option), so it can't send Authorization: Bearer .... A few options:

  • Put the token in the query string: simple, but the token ends up in proxy logs and browser history. Best avoided.
  • Use a polyfill instead of EventSource so you can send headers: works, but you lose the built-in reconnection.
  • An HttpOnly cookie: the client calls an endpoint (authenticated with a Bearer token like every other API call) that sets an HttpOnly cookie holding a token, then opens the EventSource. On the server, a filter reads the cookie and validates the token with the same JwtDecoder as the rest of the API.

Personal OS uses the third. The filter only applies when the request isn't already authenticated by a header, so the standard Bearer flow still takes precedence.

One trade-off to be aware of: the token is only checked when the stream opens. The longer a stream stays open, the longer a revoked session can keep its connection. That's the second reason the "no data" principle matters: the stream only carries table names, and data still goes through REST, where every request is authenticated. The stream's lifetime (60 minutes here) then becomes just a balance between how often clients reconnect and how quickly a revocation takes effect.

Several instances: Redis Pub/Sub

Each instance only knows the SSE connections open to itself. A user edits data through instance A while their phone is connected to instance B. The answer is one interface with two implementations:

public interface SyncEventBus {
    void publish(String userId, String table);
}
  • LocalSyncEventBus: a single instance, calling straight into the in-memory registry.
  • RedisSyncEventBus: publishes to a Redis channel. Every instance subscribes and delivers to its own connections.
Sequence diagram with five participants: laptop, instance A, Redis, instance B and phone. The laptop writes through REST to instance A. A publishes the user and table to Redis. Redis delivers it to every subscriber, including instance B. B sends the SSE hint changed: tasks to the phone, and the phone pulls the data through the REST sync API.LaptopAinstanceRedisBinstancePhoneREST writepublish(user, tasks)to everysubscriberSSE:changed:tasksREST sync:pull the data
The stream only says which table changed. The data itself still goes through the authenticated REST sync.

If publishing to Redis fails, it's logged as a warning. Following the principle above, a lost hint is fine.

The first thing to break: one slow client slows everyone

A common misconception is that "each SSE connection holds a thread". With Spring MVC that's not quite true: SseEmitter runs on async Servlet processing, so the request thread goes back to the pool as soon as the controller returns the emitter. An open connection that isn't sending anything costs no thread.

The problem is sending. SseEmitter.send() writes to the socket and blocks until the write completes. A client on a slow network (with a full TCP buffer) makes that call block for a long time. And in the original design, both sending paths ran on shared threads:

  • Heartbeats ran on the @Scheduled thread. By default Spring has one thread for all @Scheduled jobs (see Spring Boot's task scheduling defaults), shared with every other job.
  • Change hints were sent on Redis's listener thread, also a single thread per instance.

So one slow client stalled the whole send loop. Other clients missed heartbeats, proxies assumed the connections were dead and cut them, everyone reconnected at once (a reconnect storm), and every reconnect triggered a pull. That's the first limit you hit as devices grow, before the database or the connection pool.

The fix: a separate, bounded pool for writes

Shortened from SyncEventRegistry.java:

private final ThreadPoolExecutor deliverExecutor = new ThreadPoolExecutor(
    4, 4, 60, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(10_000),
    namedDaemonThreadFactory("sse-deliver-"));

private void enqueueSend(String userId, SseEmitter emitter, SseEventBuilder event) {
    try {
        deliverExecutor.execute(() -> send(userId, emitter, event));
    } catch (RejectedExecutionException e) {
        // Queue full: drop this hint; the client still reconciles periodically.
    }
}

Three choices worth noting:

  • A small pool (4 threads). Writing is I/O-bound, not CPU-bound, so a few threads serve many connections. A slow client holds just one thread and never blocks the caller.
  • A bounded queue (10,000). Without a bound, slow clients grow the queue until memory runs out.
  • Drop when full, not CallerRunsPolicy. CallerRunsPolicy is the familiar "safe" choice, but here the "caller" is the heartbeat thread or the Redis listener thread. Having them run the write themselves rebuilds exactly the blocking I set out to avoid. Dropping a hint is safe, thanks to the principle at the top.

Also, SseEmitter isn't safe for concurrent sends, so all sends to the same emitter must be serialized.

Coalescing hints within 300 ms

A large action on the client (copying a whole week, importing data) pushes dozens of consecutive batches into the same table. Every batch commit publishes a hint, which means dozens of SSE writes to each device, each one triggering a pull.

The fix: hints for the same (user, table) arriving within 300 ms are merged into one send. That's long enough to cover back-to-back batches and short enough that other devices still see the change almost instantly. One subtle detail: the "send pending" marker is removed before sending, so a hint that arrives mid-send schedules a new send instead of being swallowed.

Other small details

  • A heartbeat every 25 seconds (an SSE comment the client never sees), so proxies and load balancers don't cut "silent" connections. Many proxies drop idle connections after about a minute; Nginx's default proxy_read_timeout, for example, is 60 seconds.
  • A connected event is sent synchronously as soon as the stream opens, so the client knows the connection is ready. It's the only write that skips the pool, since it runs on the request's own thread for a brand-new emitter.
  • Clean up emitters in all three callbacks: onCompletion, onTimeout and onError.

When to move to WebFlux

For a few hundred devices, the design above is plenty. At thousands of concurrent connections, other limits show up: Tomcat's maximum connections, file descriptors, and the cost of blocking writes on threads. There are two directions worth considering then:

  • WebFlux with Flux<ServerSentEvent>: an event-loop model, non-blocking writes, backpressure.
  • Java 21 virtual threads (spring.threads.virtual.enabled=true since Spring Boot 3.2): keep the blocking code style while threads become very cheap.

Because of the SyncEventBus interface and a stream that only carries table names, either move needs no frontend changes: the text/event-stream protocol and the endpoint stay the same.

Rather than guessing when to switch, measure. Metrics worth watching: live JVM threads, Tomcat's current connections, the send pool's queue size, and the number of dropped hints.

Summary

  • SSE fits when the server only needs to tell clients that something changed: it's plain HTTP, and EventSource reconnects on its own.
  • Send hints, not data. The stream carries only the table name, and clients pull through the authenticated sync API, so a lost hint costs at most one reconciliation cycle.
  • EventSource can't send headers. An HttpOnly cookie validated by the same JwtDecoder keeps the token out of URLs, but the token is checked only when the stream opens.
  • With several instances, one SyncEventBus interface and a Redis Pub/Sub implementation get each hint to whichever instance holds the connection.
  • The first thing to break is a blocking send() on shared threads: one slow client stalls heartbeats for everyone. Move writes to a small, bounded pool and drop hints when it's full.
  • Coalesce hints per user and table within 300 ms, and send a heartbeat every 25 seconds so proxies keep idle connections open.
  • Measure live threads, Tomcat connections, the send queue and dropped hints before moving to WebFlux or virtual threads.

If you've built real-time updates with Spring and learned different lessons, I'd love to compare notes. My email is on the home page.