Spring Boot SSE Integration: Simpler Server Push Without WebSocket Overhead

This article details implementing Server-Sent Events (SSE) in Spring Boot for real-time server-to-client push, covering SseEmitter endpoints, connection registry with cleanup, heartbeat, Last-Event-ID reconnection, cluster broadcasting via Redis Pub/Sub, Base64 binary transfer, and production tuning for Nginx and thread pools.

Xiaolin Talks Programming
Xiaolin Talks Programming
Xiaolin Talks Programming
Spring Boot SSE Integration: Simpler Server Push Without WebSocket Overhead

What Is SSE and Why It's Simpler Than WebSocket

SSE (Server-Sent Events) is an HTML5 standard where the server pushes a text stream to the client over a regular HTTP GET request. The response uses Content-Type: text/event-stream and keeps the connection open. Unlike WebSocket (bidirectional), SSE is unidirectional (server → client). If the client only receives data, WebSocket adds unnecessary complexity: handshake, heartbeats, manual reconnection. SSE provides automatic reconnection natively in browsers with no extra libraries.

The SSE message format is simple: each field on its own line, blank line between messages.

id: 1
event: stock
data: {"symbol": "AAPL", "price": 175.32}

: heartbeat
event

defaults to message; id enables resume after disconnect; retry sets reconnect interval; lines starting with : are comments (used for heartbeats). The client uses the native EventSource API:

const source = new EventSource('/api/sse/connect');
source.addEventListener('stock', (event) => {
  const data = JSON.parse(event.data);
  console.log(data);
});
source.onerror = (error) => {
  // auto-reconnect happens; log here
};

Building an SSE Endpoint with Spring Boot

Since Spring MVC 4.2, SseEmitter provides asynchronous SSE responses. A controller endpoint:

@RestController
@RequestMapping("/api/sse")
public class SseController {
  private final SseSessionRegistry registry;
  public SseController(SseSessionRegistry registry) { this.registry = registry; }
  @GetMapping(value = "/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
  public SseEmitter connect() {
    SseEmitter emitter = new SseEmitter(0L); // 0L = no timeout
    String sessionId = UUID.randomUUID().toString();
    registry.add(sessionId, emitter);
    emitter.onCompletion(() -> registry.remove(sessionId));
    emitter.onTimeout(() -> registry.remove(sessionId));
    emitter.onError((ex) -> registry.remove(sessionId));
    try {
      emitter.send(SseEmitter.event()
        .name("connected")
        .data("session=" + sessionId));
    } catch (IOException e) {
      emitter.completeWithError(e);
    }
    return emitter;
  }
}
produces

must be text/event-stream. SseEmitter.event() sets event name and data. To push to a specific connection, retrieve its emitter from the registry and call send().

Connection Registry: Avoid Raw Static Maps

SSE connections are long-lived; the server must track each SseEmitter. Use a thread-safe ConcurrentHashMap:

@Component
public class SseSessionRegistry {
  private final Map<String, SseEmitter> sessions = new ConcurrentHashMap<>();
  public void add(String sessionId, SseEmitter emitter) { sessions.put(sessionId, emitter); }
  public void remove(String sessionId) { sessions.remove(sessionId); }
  public SseEmitter get(String sessionId) { return sessions.get(sessionId); }
  public Collection<SseEmitter> getAll() { return sessions.values(); }
  public int size() { return sessions.size(); }
  public void removeByEmitter(SseEmitter emitter) {
    sessions.values().removeIf(value -> value == emitter);
  }
}

Callbacks ( onCompletion, onTimeout, onError) may not fire if the client crashes or network fails, leaving "zombie" connections. A scheduled task sends a comment (heartbeat) to detect dead connections:

@Component
public class SseSessionScheduler {
  private final SseSessionRegistry registry;
  public SseSessionScheduler(SseSessionRegistry registry) { this.registry = registry; }
  @Scheduled(fixedRate = 30000)
  public void purgeExpiredConnections() {
    for (SseEmitter emitter : registry.getAll()) {
      try {
        emitter.send(SseEmitter.event().comment("ping"));
      } catch (Exception e) {
        registry.removeByEmitter(emitter);
        emitter.complete();
      }
    }
  }
}

Add @EnableScheduling to the application class.

Heartbeat to Prevent Proxy Timeouts

Idle SSE connections are dropped by Nginx/firewalls. The comment heartbeat keeps TCP alive. SseEmitter default timeout ~30s (container-dependent). Setting 0L disables timeout but risks leaking threads if client disappears. Recommended: new SseEmitter(60_000L) with 15s heartbeat interval; timeout ~4x heartbeat. Combine heartbeat and cleanup in one task:

@Scheduled(fixedRate = 15000)
public void heartbeatAndClean() {
  for (SseEmitter emitter : registry.getAll()) {
    try {
      emitter.send(SseEmitter.event().comment("heartbeat"));
    } catch (Exception e) {
      registry.removeByEmitter(emitter);
      emitter.complete();
    }
  }
}

Reconnection and Resume with Last-Event-ID

EventSource

auto-reconnects. If the server sent an id field, the reconnect request includes Last-Event-ID header. Server reads it and replays missed events:

@GetMapping(value = "/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter connect(@RequestHeader(value = "Last-Event-ID", required = false) String lastEventId) {
  SseEmitter emitter = new SseEmitter(60_000L);
  String sessionId = registry.add(emitter);
  if (lastEventId != null) {
    List<EventMessage> missed = messageService.fetchAfterId(Long.parseLong(lastEventId));
    for (EventMessage msg : missed) {
      try {
        emitter.send(SseEmitter.event()
          .name("message")
          .id(String.valueOf(msg.getId()))
          .data(msg.getPayload()));
      } catch (Exception e) { break; }
    }
  }
  return emitter;
}

Note: manual reconnection (custom backoff) won't send Last-Event-ID automatically; must pass via URL param. Default reconnection sends the header but cannot add custom headers; authentication typically relies on Cookie (sent automatically) or URL token.

Cluster Broadcasting with Redis Pub/Sub

In a clustered deployment, clients connect to different instances. Use Redis Pub/Sub to broadcast messages across instances.

graph LR
  App1[应用实例 1] -- publish --> Redis[(Redis Pub/Sub)]
  App2[应用实例 2] -- publish --> Redis
  Redis -- subscribe --> App1
  Redis -- subscribe --> App2
  App1 -- push --> Client1[客户端 1]
  App2 -- push --> Client2[客户端 2]

Add spring-boot-starter-data-redis. Configure listener container:

@Configuration
public class RedisPubSubConfig {
  @Bean
  public RedisMessageListenerContainer redisContainer(RedisConnectionFactory factory,
      MessageListenerAdapter listenerAdapter) {
    RedisMessageListenerContainer container = new RedisMessageListenerContainer();
    container.setConnectionFactory(factory);
    container.addMessageListener(listenerAdapter, new PatternTopic("sse-notification"));
    return container;
  }
  @Bean
  public MessageListenerAdapter listenerAdapter(SseBroadcastListener listener) {
    return new MessageListenerAdapter(listener, "onMessage");
  }
}

Critical pitfall: MessageListenerAdapter 's second argument is the method name. The listener method signature must be onMessage(String message, byte[] pattern) — the second parameter is the subscription pattern, not the message body. Many mistakenly treat it as the message.

@Component
public class SseBroadcastListener {
  private final SseSessionRegistry registry;
  public SseBroadcastListener(SseSessionRegistry registry) { this.registry = registry; }
  public void onMessage(String message, byte[] pattern) {
    NotificationDTO dto = JSON.parseObject(message, NotificationDTO.class);
    for (SseEmitter emitter : registry.getAll()) {
      try {
        emitter.send(SseEmitter.event().name("notice").data(dto));
      } catch (Exception e) {
        // push failed; cleanup task will remove
      }
    }
  }
}

Publish with

redisTemplate.convertAndSend("sse-notification", JSON.toJSONString(dto))

. Warning: Redis Pub/Sub is fire-and-forget; messages sent while an instance is down are lost. For critical data, use Redis Streams or RabbitMQ persistent queues.

Sending Binary Files via Base64

SSE is text-only; encode binary as Base64 in the data field.

byte[] fileBytes = Files.readAllBytes(path);
String base64Data = Base64.getEncoder().encodeToString(fileBytes);
emitter.send(SseEmitter.event()
  .name("file")
  .id(String.valueOf(fileId))
  .data("{\"filename\":\"report.pdf\",\"content\":\"" + base64Data + "\"}"));

Client decodes:

source.addEventListener('file', (event) => {
  const response = JSON.parse(event.data);
  const binaryString = atob(response.content);
  const bytes = new Uint8Array(binaryString.length);
  for (let i = 0; i < binaryString.length; i++) {
    bytes[i] = binaryString.charCodeAt(i);
  }
  const blob = new Blob([bytes], { type: 'application/pdf' });
  // trigger download
});

Base64 inflates size by ~33%; unsuitable for large files. Use HTTP chunked download or object storage direct links for big files. SSE fits small, high-frequency messages (stock ticks, notifications, AI streaming).

Production Considerations

Nginx Buffering

Nginx buffers SSE by default, destroying real-time delivery. Disable buffering in the location block:

location /api/sse/ {
  proxy_buffering off;
  proxy_cache off;
  proxy_read_timeout 3600s;
  proxy_http_version 1.1;
  proxy_set_header Connection "";
}
proxy_buffering off

forces immediate flush; proxy_read_timeout prevents idle disconnect (default 60s).

Thread Pool Sizing

Each SSE connection occupies a Tomcat thread. Increase max threads:

server.tomcat.threads.max=400
server.tomcat.threads.min-spare=50

For C10K scale, consider Spring WebFlux (non-blocking), but MVC async suffices for most cases.

HTTP/1.1 Connection Limit

Browsers limit ~6 concurrent connections per domain. Multiple SSE connections can block other requests. Design accordingly.

Security

SSE cannot carry custom headers during handshake. Auth via Cookie (auto-sent on reconnect) or URL token (logs may leak token). Prefer Cookie + HTTPS. Configure CORS to prevent cross-site abuse.

Monitoring

Expose metrics: active connections, push rate, disconnect rate, registry size. Use Actuator or a custom @RestController endpoint; aggregate across cluster.

Summary

SSE is not new but often overlooked. For server-to-client push, SSE halves code complexity and avoids WebSocket pitfalls. Choose SSE for unidirectional, WebSocket for bidirectional — match the tool to the requirement, not the hype.

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.

Spring BootWebSocketServer-Sent EventsReal-time PushSSEEventSourceSseEmitterRedis Pub/Sub
Xiaolin Talks Programming
Written by

Xiaolin Talks Programming

Focuses on sharing original technical insights. Senior architect at a top tech company with years of experience in technical architecture and management, and extensive interview experience. Offers one-on-one technical coaching, guiding you from beginner to architecture design to technical management. Follow for free learning resources. Free one-on-one interview coaching to help you land offers quickly.

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.