SpringBoot SSE vs WebSocket: Complete Guide to Server-Sent Events for Unidirectional Push

This article compares SSE and WebSocket for server push, details SpringBoot integration with SseEmitter, WebFlux, and raw HttpServletResponse, provides a production-ready connection manager with heartbeat and clustering via Redis, and demonstrates AI streaming and real-time dashboard use cases.

Java Tech Workshop
Java Tech Workshop
Java Tech Workshop
SpringBoot SSE vs WebSocket: Complete Guide to Server-Sent Events for Unidirectional Push

Introduction: Why SSE Over WebSocket for Unidirectional Push

The author encountered a typical scenario while building an AI application: a large language model generates tokens one by one, and the frontend must display them incrementally like ChatGPT's typewriter effect. The first instinct was to use WebSocket, but implementing it required adding dependencies, handling handshake upgrades, writing custom heartbeats and reconnection logic, and configuring Nginx Upgrade headers — a significant operational cost for a purely unidirectional push requirement.

Switching to SSE (Server-Sent Events) achieved the same result with two-thirds less backend code, while the browser's native EventSource API provides automatic reconnection out of the box. Notably, ChatGPT, Claude, 文心一言, and 通义千问 all use SSE for their streaming outputs.

Four Server Push Approaches Compared

The article compares four mechanisms:

Short Polling : Client sends periodic requests. Poor real-time performance, high server overhead from many empty responses, simplest complexity. Almost never recommended.

Long Polling : Request hangs until data arrives. Better real-time, medium overhead due to frequent connection rebuilds, moderate complexity. Suitable for legacy system compatibility.

SSE : Single HTTP connection, server pushes continuously. Good real-time, low overhead (one long connection), low complexity. Ideal for unidirectional push : notifications, progress, streaming output.

WebSocket : Full-duplex persistent connection. Best real-time, low overhead, high complexity (custom protocol, heartbeats, reconnection). Required for bidirectional communication : IM, collaborative editing, gaming.

Summary : If your scenario is "server speaks, client listens", SSE is likely the most cost-effective choice.

What Is SSE?

SSE is part of the HTML5 standard, not a new framework. It is essentially a regular HTTP request where the server sets Content-Type: text/event-stream and keeps the connection open, continuously writing text.

SSE Message Format

An SSE response is plain text with messages separated by a blank line. Key fields: data: Message body. Multiple data: lines are concatenated with \n. event: Event name (default message). Frontend listens via addEventListener('xxx'). id: Event ID. On reconnection, browser sends it as Last-Event-ID header for message replay. retry: Reconnection interval in milliseconds (browser default ~3000ms). : comment: Comment line ignored by browser, used for heartbeat keep-alive .

Blank line : Message terminator. Missing it means the frontend never receives the message.

Browser API: EventSource (20 Lines)

const es = new EventSource('/sse/subscribe/1001');
es.onmessage = (e) => console.log('Default message:', e.data, 'id=', e.lastEventId);
es.addEventListener('done', (e) => { console.log('Stream end:', e.data); es.close(); });
es.onerror = (e) => { console.warn('Connection error, readyState=', es.readyState); };
EventSource

provides three built-in capabilities:

✅ Automatic reconnection on network glitches or server restart.

✅ Automatic Last-Event-ID header on reconnect, enabling server-side message replay.

✅ Automatic event parsing — no custom protocol parsing needed.

⚠️ Critical limitation : EventSource cannot send custom headers (e.g., Authorization ). Authentication must use cookies or URL query parameters ( ?token=xxx over HTTPS).

SSE vs WebSocket: When Can SSE Replace WebSocket?

Detailed comparison across dimensions:

Direction : SSE unidirectional (server→client); WebSocket full-duplex.

Protocol : SSE uses standard HTTP ( text/event-stream); WebSocket uses ws:// with Upgrade handshake.

Reconnection : SSE native browser support; WebSocket requires custom implementation.

Data format : SSE text-only UTF-8 (binary via Base64); WebSocket text + binary ( ArrayBuffer).

Heartbeat : SSE sends a comment line ( :ping); WebSocket needs custom ping/pong.

Proxy/firewall : SSE traverses 80/443 naturally; WebSocket needs gateway Upgrade support (legacy Nginx requires config).

Browser connection limit : SSE subject to HTTP/1.1 same-origin limit of 6 connections; WebSocket no such limit (hundreds).

Server implementation : SSE via Spring MVC SseEmitter; WebSocket needs STOMP or custom sub-protocol.

Debugging : SSE with curl -N; WebSocket requires specialized tools.

Overhead : SSE minimal per-message; WebSocket small frames but more complex handshake/heartbeat.

Six Scenarios Where SSE Can Replace WebSocket

🤖 AI LLM streaming output — ChatGPT-style typewriter effect; SSE is de facto standard.

🔔 In-app notifications — New message badges, approval alerts.

📊 Real-time dashboards — Monitoring metrics, order boards, stock quotes.

📈 Long-task progress bars — Report export, batch processing progress.

📜 Real-time log streaming — Deployment logs, tail -f style.

🔄 Config/state push — Feature flags, rate-limit rule distribution.

Three Scenarios Where SSE Should Not Be Used

• Client sends frequent messages (chat rooms, bullet comments, game sync) → WebSocket.

• Large binary data transfer (audio/video streams, file chunks) → WebSocket.

• Complex sub-protocols/interactions (collaborative editing with OT algorithms) → WebSocket.

💡 Rule of thumb : Ask "Does the client need to speak actively?" No → SSE; Yes → WebSocket. Don't choose WebSocket for "technical advancement" — operational cost and cognitive load are real.

Three Ways to Integrate SSE in SpringBoot

4.1 Approach 1: SseEmitter (Spring MVC, Most Common ✅)

Available since Spring 4.2, works with Boot 2.x/3.x, zero extra dependencies :

@GetMapping(value = "/subscribe/{userId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe(@PathVariable String userId) {
    SseEmitter emitter = new SseEmitter(60_000L); // timeout 60s, 0L/-1 = no timeout
    emitterPool.put(userId, emitter);
    emitter.onCompletion(() -> emitterPool.remove(userId));
    return emitter;
}

4.2 Approach 2: WebFlux Reactive ( Flux , Most Elegant)

Suited for reactive stacks:

@GetMapping(value = "/flux", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> stream() {
    return Flux.interval(Duration.ofSeconds(1))
        .map(seq -> ServerSentEvent.<String>builder()
            .id(String.valueOf(seq))
            .event("tick")
            .data("第 " + seq + " 秒")
            .build());
}

4.3 Approach 3: Raw HttpServletResponse (Not Recommended but Good to Know)

@GetMapping("/raw")
public void raw(HttpServletResponse response) throws IOException, InterruptedException {
    response.setContentType("text/event-stream;charset=UTF-8");
    response.setHeader("Cache-Control", "no-cache");
    response.setHeader("X-Accel-Buffering", "no"); // disable Nginx buffering
    PrintWriter writer = response.getWriter();
    for (int i = 0; i < 10; i++) {
        writer.write("data: 第 " + i + " 条

");
        writer.flush(); // ⚠️ must flush
        Thread.sleep(1000);
    }
    writer.close();
}
Production recommendation: Approach 1 ( SseEmitter ) — it encapsulates timeout, async thread pool, lifecycle callbacks, and exception propagation, integrating seamlessly with Spring MVC. The rest of this article uses Approach 1.

Building a Production-Ready SSE Push Service

5.1 Dependencies (Boot 3.x, JDK 17+)

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- Optional for cluster broadcasting -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>
No SSE-specific dependency needed; spring-boot-starter-web already includes SseEmitter .

5.2 Connection Manager (Core — Bookmark This)

The heart of the solution: connection pool + lifecycle cleanup + heartbeat + broadcast .

@Slf4j
@Component
public class SseEmitterManager {
    private final Map<String, CopyOnWriteArrayList<SseEmitter>> emitters = new ConcurrentHashMap<>();
    private final AtomicLong eventId = new AtomicLong(0);
    private final ScheduledExecutorService heartbeatExecutor = Executors.newSingleThreadScheduledExecutor(r -> {
        Thread t = new Thread(r, "sse-heartbeat");
        t.setDaemon(true);
        return t;
    });

    public SseEmitterManager() {
        heartbeatExecutor.scheduleAtFixedRate(this::heartbeat, 25, 25, TimeUnit.SECONDS);
    }

    public SseEmitter connect(String userId) {
        SseEmitter emitter = new SseEmitter(0L); // 0L = no timeout, rely on heartbeat + active cleanup
        emitters.computeIfAbsent(userId, k -> new CopyOnWriteArrayList<>()).add(emitter);

        emitter.onCompletion(() -> { remove(userId, emitter); log.info("[SSE] Connection completed userId={}, remaining {}", userId, count()); });
        emitter.onTimeout(() -> { log.warn("[SSE] Connection timeout userId={}", userId); remove(userId, emitter); });
        emitter.onError(e -> { log.warn("[SSE] Connection error userId={}, msg={}", userId, e.getMessage()); remove(userId, emitter); });

        try {
            emitter.send(SseEmitter.event()
                .id(String.valueOf(eventId.incrementAndGet()))
                .name("connected")
                .data("SSE connection established")
                .reconnectTime(3000L)); // tell browser to reconnect after 3s
        } catch (IOException e) { remove(userId, emitter); }
        return emitter;
    }

    public void send(String userId, String eventName, Object data) {
        List<SseEmitter> list = emitters.get(userId);
        if (list == null || list.isEmpty()) { log.debug("[SSE] User {} no online connections, message dropped", userId); return; }
        long id = eventId.incrementAndGet();
        List<SseEmitter> dead = new ArrayList<>();
        for (SseEmitter emitter : list) {
            try { emitter.send(SseEmitter.event().id(String.valueOf(id)).name(eventName).data(data).reconnectTime(3000L)); }
            catch (IOException | IllegalStateException e) { dead.add(emitter); }
        }
        dead.forEach(e -> remove(userId, e));
    }

    public void broadcast(String eventName, Object data) {
        emitters.keySet().forEach(userId -> send(userId, eventName, data));
    }

    private void heartbeat() {
        int total = count();
        if (total == 0) return;
        for (Map.Entry<String, CopyOnWriteArrayList<SseEmitter>> e : emitters.entrySet()) {
            List<SseEmitter> dead = new ArrayList<>();
            for (SseEmitter emitter : e.getValue()) {
                try { emitter.send(SseEmitter.event().comment("ping")); }
                catch (Exception ex) { dead.add(emitter); }
            }
            dead.forEach(x -> remove(e.getKey(), x));
            // second pass to actually send (avoids concurrent modification)
            for (SseEmitter emitter : e.getValue()) {
                try { emitter.send(SseEmitter.event().comment("ping")); }
                catch (Exception ignored) { }
            }
        }
        log.debug("[SSE] Heartbeat done, online connections {}", total);
    }
    // ... close, remove, count, onlineUsers methods omitted for brevity
}
The heartbeat method deliberately separates dead-connection collection from sending to avoid logic confusion during iteration. In practice you could merge into one loop using an Iterator or collect dead connections first.

5.3 Controller

@Slf4j
@RestController
@RequestMapping("/sse")
@RequiredArgsConstructor
public class SseController {
    private final SseEmitterManager manager;

    @GetMapping(value = "/subscribe/{userId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter subscribe(@PathVariable String userId,
                                @RequestHeader(value = "Last-Event-ID", required = false) String lastEventId) {
        log.info("[SSE] User {} connecting, Last-Event-ID={}", userId, lastEventId);
        if (lastEventId != null) { /* TODO: replay missed messages using lastEventId (see 7.6) */ }
        return manager.connect(userId);
    }

    @PostMapping("/push/{userId}")
    public Map<String, Object> push(@PathVariable String userId, @RequestBody Map<String, Object> body) {
        manager.send(userId, "message", body);
        Map<String, Object> res = new HashMap<>();
        res.put("ok", true);
        res.put("online", manager.onlineUsers().contains(userId));
        return res;
    }

    @PostMapping("/broadcast")
    public Map<String, Object> broadcast(@RequestBody Map<String, Object> body) {
        manager.broadcast("notice", body);
        Map<String, Object> res = new HashMap<>();
        res.put("ok", true);
        res.put("connections", manager.count());
        return res;
    }

    @GetMapping("/stats")
    public Map<String, Object> stats() {
        Map<String, Object> res = new HashMap<>();
        res.put("connections", manager.count());
        res.put("users", manager.onlineUsers());
        return res;
    }
}

5.4 Frontend Demo (Vanilla JS, Works with Vue/React)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>SSE Push Demo</title>
    <style> ... </style>
</head>
<body>
    <h2>SSE Real-time Push Demo</h2>
    <button onclick="connect()">Connect</button>
    <button onclick="stop()">Disconnect</button>
    <div id="log"></div>
    <script>
        let es = null;
        const logEl = document.getElementById('log');
        const log = (msg) => { ... };
        function connect() {
            if (es) return;
            es = new EventSource('/sse/subscribe/1001');
            es.addEventListener('connected', e => log('✅ Connected: ' + e.data));
            es.onmessage = e => log('📩 Message: ' + e.data);
            es.addEventListener('notice', e => log('📢 Broadcast: ' + e.data));
            es.addEventListener('done', () => { log('🏁 Stream end'); stop(); });
            es.onerror = () => { log('⚠️ Error, readyState=' + es.readyState); if (es.readyState === EventSource.CLOSED) { es = null; } };
        }
        function stop() { if (es) { es.close(); es = null; log('🔌 Disconnected'); } }
    </script>
</body>
</html>

5.5 Verify with curl (No Frontend Needed)

curl -N -H "Accept: text/event-stream" http://localhost:8080/sse/subscribe/1001
-N

disables curl's own buffering; without it you see nothing — same class of issue as Nginx's proxy_buffering.

Push a message from another terminal:

curl -X POST http://localhost:8080/sse/push/1001 \
     -H "Content-Type: application/json" \
     -d '{"msg":"Hello, SSE"}'

Internals: How SseEmitter Works

SseEmitter

extends ResponseBodyEmitter. Core flow:

Controller returns SseEmitter → Spring MVC detects ResponseBodyEmitter and uses ResponseBodyEmitterReturnValueHandler.

Servlet async starts : request.startAsync() suspends the AsyncContext, releasing the Tomcat worker thread immediately (not one thread per connection!).

Response headers written : Content-Type: text/event-stream, flushed, HTTP 200 returned — browser considers connection established.

Business thread calls emitter.send() → obtains output stream via AsyncContext, writes SSE-formatted id:/event:/data:\n\n and flushes.

Lifecycle ends : complete() / completeWithError() /timeout/IOException → triggers onCompletion → closes AsyncContext.

Three Must-Understand Points

Does not occupy business threads : Async servlet mechanism returns thread to pool while connection is idle; but writing data uses the async thread pool .

Timeout must be set explicitly : new SseEmitter() uses container default (may be 30s). Recommend 0L + heartbeat.

Must configure async thread pool : Default is SimpleAsyncTaskExecutor (unbounded, new thread per task). Production must replace it .

6.1 Required: Async Thread Pool Configuration

@Configuration
public class AsyncConfig implements WebMvcConfigurer {
    @Override
    public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(50);
        executor.setMaxPoolSize(200);
        executor.setQueueCapacity(1000);
        executor.setThreadNamePrefix("sse-async-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        configurer.setTaskExecutor(executor);
        configurer.setDefaultTimeout(0L); // no timeout, rely on heartbeat + active cleanup
    }
}
Spring Boot 3.2+ strongly recommends enabling virtual threads : spring.threads.virtual.enabled=true . For SSE's "many connections, little compute" I/O pattern, virtual threads reduce memory usage by an order of magnitude.

AI LLM Streaming Output — The Most Common SSE Use Case

Backend connects to LLM, pushes each token to frontend via SSE.

@RestController
@RequestMapping("/ai")
@RequiredArgsConstructor
public class AiStreamController {
    private final SseEmitterManager manager;
    private final AiClient aiClient;

    @GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter chat(@RequestParam String prompt) {
        SseEmitter emitter = new SseEmitter(5 * 60 * 1000L); // 5 min timeout
        CompletableFuture.runAsync(() -> {
            try {
                aiClient.streamChat(prompt, new AiClient.TokenListener() {
                    @Override
                    public void onToken(String token) {
                        try { emitter.send(SseEmitter.event().name("delta").data(Collections.singletonMap("content", token))); }
                        catch (IOException e) { /* client disconnected */ }
                    }
                    @Override
                    public void onFinish(String fullText) {
                        try { emitter.send(SseEmitter.event().name("done").data("[DONE]")); }
                        catch (IOException ignored) { }
                        emitter.complete();
                    }
                    @Override
                    public void onError(Throwable t) { emitter.completeWithError(t); }
                });
            } catch (Exception e) { emitter.completeWithError(e); }
        });
        emitter.onTimeout(emitter::complete);
        emitter.onError(e -> emitter.complete());
        return emitter;
    }
}

Frontend:

const es = new EventSource(`/ai/chat?prompt=${encodeURIComponent(prompt)}`);
let answer = '';
es.addEventListener('delta', e => { answer += JSON.parse(e.data).content; renderMarkdown(answer); });
es.addEventListener('done', () => es.close());
Tip: If frontend needs to POST large context, EventSource only supports GET. Common pattern: POST to create a session and get a sessionId, then GET to establish the SSE stream — this is how mainstream AI products do it.

Cluster Deployment: Cross-Node Broadcasting

SseEmitter

is an in-memory object ; connections on node A are invisible to node B. Multi-instance deployment requires middleware.

// Publisher: any node publishes events to Redis channel
public void broadcastCluster(String eventName, Object data) {
    Map<String, Object> msg = new HashMap<>();
    msg.put("event", eventName);
    msg.put("data", data);
    redisTemplate.convertAndSend("sse:broadcast", msg);
}

// Subscriber: each node listens and pushes only to its local connections
@RedisListener(topic = "sse:broadcast")
public void onMessage(Map<String, Object> msg) {
    emitters.keySet().forEach(userId -> send(userId, (String) msg.get("event"), msg.get("data")));
}

Use Redis Pub/Sub (real-time, lightweight, loss-tolerant) or MQ (Rabbit/RocketMQ/Kafka) if persistence and backlog are needed.

Each node only pushes to its local connections , naturally deduplicating.

For "no offline message loss", subscribe using Last-Event-ID to replay from DB/Redis.

📌 Environment : Spring Boot 3.2+, JDK 17+, based on spring-boot-starter-web .
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.

WebSocketSpringBootServer-Sent EventsReal-time PushSSEAI StreamingRedis Pub/SubAsync Servlet
Java Tech Workshop
Written by

Java Tech Workshop

Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.

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.