Introduction: The Need for Real-Time Push
Modern web users expect instant updates. When a stock price moves, traders need to see it immediately. When a food delivery driver turns a corner, customers watch the vehicle move on the map. When an automated build finishes in a CI pipeline, developers see the green checkmark without clicking refresh. In the early days of the web, browsers had to poll servers repeatedly. Polling wasted massive bandwidth and flooded servers with empty HTTP requests. Servers spent enormous compute resources answering repetitive inquiries with zero new data.
To eliminate polling, engineers adopted persistent push protocols. For many years, WebSockets became the default choice. However, WebSockets require protocol upgrades, custom binary framing, and complex connection state management. WebSockets struggle behind enterprise corporate proxies and corporate firewalls. They do not work natively with HTTP caching or HTTP/2 multiplexing. Maintaining custom heartbeat logic and reconnect backoffs across mobile fleets created continuous operational friction.
Server-Sent Events (SSE) offer a simpler, standard alternative. SSE runs over standard HTTP/1.1 and HTTP/2. The server pushes text events, and the browser listens using the native `EventSource` API. There is no custom framing or handshake upgrade needed. The transport is plain HTTP with a specialized MIME type. In this tutorial, we combine Spring Boot 4, Java 25 Virtual Threads, and Angular 22 Signals. We show how to push millions of live events to thousands of connected clients with ultra-low latency and minimal memory overhead.
Protocol Comparison: SSE vs WebSockets vs Long Polling
Selecting the right streaming protocol depends on the communication flow of your application. Real-time protocols differ fundamentally in directional capabilities, firewall behavior, and architectural complexity.

WebSockets provide full-duplex, bidirectional communication over a single TCP connection. Both the client and the server can send data frames at any time. WebSockets shine in multiplayer gaming and peer-to-peer audio chat where two-way low-latency interaction is non-negotiable. However, for ninety percent of real-time web applications, the client only needs to receive data from the server. The client sends user actions, form submissions, and commands using standard REST APIs. Using a bidirectional socket for unidirectional data is architectural overkill.
Server-Sent Events are unidirectional: the server pushes updates, and the client receives them. Because SSE is standard HTTP, it traverses corporate firewalls, reverse proxies, and cloud load balancers seamlessly. SSE supports automatic reconnection out of the box. If the WiFi drops for three seconds, the browser reconnects automatically without custom JavaScript code. Furthermore, over HTTP/2, multiple SSE streams multiplex over a single TCP connection, avoiding browser connection pool limits. Compare this with our guide on Full-Stack Signals with Angular 22 and Spring Boot 4.
Scaling SseEmitter with Java 25 Virtual Threads
In traditional Spring MVC on Java 8 or Java 11, running Server-Sent Events at scale was notoriously difficult. An open SSE connection holds an HTTP connection open indefinitely. In older servlet containers, each open connection consumed one dedicated operating system thread.

Operating system threads are expensive. Each thread consumes one megabyte of off-heap stack memory. If five thousand clients connected to an SSE feed, the server allocated five gigabytes of memory just for thread stacks. Context switching overloaded the CPU scheduler, and thread pools became starved. Incoming REST API calls to other endpoints timed out because Tomcat had no remaining worker threads to accept new sockets. Developers were forced to use reactive WebFlux to avoid running out of threads.
Java 25 Virtual Threads completely eliminate this scalability barrier. In Spring Boot 4 with `spring.threads.virtual.enabled=true`, Tomcat assigns each incoming SSE request a lightweight virtual thread. A virtual thread uses less than a kilobyte of heap memory. When an emitter waits for the next stock price event, its virtual thread unmounts cleanly from the carrier thread. The operating system thread is immediately released to process other active work. A single modest cloud server can comfortably maintain over one hundred thousand active concurrent SSE emitters. Learn more about high-concurrency threads in High-Concurrency APIs with Java 25 Virtual Threads.
Angular 22 Signals and Zoneless Reactivity
While the backend streams data efficiently, the frontend must render those updates smoothly. In legacy Angular applications, change detection relied on Zone.js. Zone.js monkey-patched asynchronous browser APIs like `setTimeout`, `fetch`, and `addEventListener`. Every time an SSE event arrived, Zone.js triggered a top-down change detection sweep across the entire component tree.
When an SSE stream pushed fifty events per second, top-down change detection caused severe browser UI lag. The browser dropped frames, leading to jerky scrolling and high battery consumption on mobile devices.
Angular 22 introduces Zoneless change detection powered by Signals. A Signal is a wrapper around a value that notifies consumers when the value changes. Signals create a direct dependency graph between state and the DOM. When an SSE event updates a Signal, Angular repaints only the exact DOM node that displays that value. The rest of the component tree is untouched. The UI maintains a silky-smooth sixty frames per second, even under heavy live market data streams.
Bridging Browser EventSource to Angular Signals
Connecting a native browser `EventSource` to an Angular 22 component is straightforward. We encapsulate the stream inside an injectable Angular service.

The service maintains a writable signal holding the latest data snapshot:
import { Injectable, signal, computed, NgZone } from '@angular/core';
export interface PriceUpdate {
ticker: string;
price: number;
changePercent: number;
sequenceNumber: number;
}
@Injectable({ providedIn: 'root' })
export class StockStreamService {
// Writable signal storing the latest price update
private priceUpdate = signal<PriceUpdate | null>(null);
// Read-only public signal
readonly currentPrice = this.priceUpdate.asReadonly();
// Derived computed signal calculating alert state
readonly isSpike = computed(() => {
const update = this.priceUpdate();
return update ? Math.abs(update.changePercent) > 2.0 : false;
});
connect(url: string): void {
const eventSource = new EventSource(url);
eventSource.addEventListener('price_tick', (event: MessageEvent) => {
const data: PriceUpdate = JSON.parse(event.data);
this.priceUpdate.set(data);
});
eventSource.onerror = (error) => {
console.error('SSE Stream error, browser auto-reconnecting...', error);
};
}
}
In the component template, we read the signal by calling `currentPrice()`. When new prices arrive, Angular updates the text node instantly without full re-rendering.
Heartbeats, Auto-Reconnection, and Last-Event-ID
Real-world network connections fail frequently. Mobile devices switch between 5G towers and WiFi networks. Corporate firewalls silently drop idle TCP sockets after sixty seconds of inactivity. To ensure high availability, an SSE architecture must implement heartbeats and event ID recovery.

A heartbeat is an SSE comment pushed periodically by the server. In the SSE specification, lines starting with a colon (`:`) are treated as comments and ignored by the client parser. By sending `:heartbeat\n\n` every fifteen seconds, the server keeps intermediate NAT gateways and proxies alive.
When a network drop occurs, the browser’s native `EventSource` automatically attempts reconnection after a short delay. In the reconnection HTTP request, the browser includes a header called `Last-Event-ID`. This header contains the sequence ID of the last successfully received event. The Spring Boot backend inspects this header, queries an in-memory or Redis backlog buffer, and replays any missed events. The user experiences seamless streaming with zero data loss. Compare this with rate-limiting architectures in our tutorial on Distributed Rate Limiting with Spring Boot 4 and Redis.
Backpressure, Memory Leaks, and Emitter Lifecycle
Managing the lifecycle of `SseEmitter` instances is crucial for production stability. If a user closes their browser tab or navigates away, the server must release resources immediately. If the server continues attempting to push data to a closed socket, memory leaks and broken pipe exceptions accumulate rapidly in server logs.
Spring’s `SseEmitter` provides three lifecycle callback hooks:
- onCompletion: Invoked when the stream completes cleanly. Remove the emitter from active registries immediately to prevent dangling references.
- onTimeout: Invoked when the configured timeout expires. Call `emitter.complete()` and clean up resources so the client can reconnect with a fresh state.
- onError: Invoked when client disconnection, broken pipe, or network errors occur. Evict the dead emitter from your map immediately.
When broadcasting events across thousands of clients, wrap the send operation in a defensive `try-catch` block. If an `IOException` occurs, the client has disconnected. Remove the dead emitter from your `ConcurrentHashMap` immediately to prevent memory growth. In high-volume systems, consider implementing bounded queues to buffer updates during momentary client network congestion without exhausting JVM heap memory.
Step-by-Step Implementation Guide
Building a resilient SSE push streaming architecture in Spring Boot 4 requires four concise steps. We configure virtual threads, define our immutable event models, implement the stream broadcasting service, and expose the streaming controller.
Step 1: Configure Virtual Threads in Spring Boot 4
We configure `application.yml` to enable Java 25 virtual threads. Virtual threads enable thousands of simultaneous blocking push operations without thread starvation:
server:
port: 8080
spring:
application:
name: sse-angular-signals-service
threads:
virtual:
enabled: true
management:
endpoints:
web:
exposure:
include: health,info,metrics
Step 2: Model Real-Time Price Events with Invariants
We define our price update event as an immutable Java 25 record. Defensive preconditions validate that prices are positive and tickers are valid:
package com.webcodein.workshop.fullstack.model;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Objects;
public record PriceUpdateEvent(
String ticker,
BigDecimal price,
BigDecimal changePercent,
long sequenceNumber,
Instant timestamp
) {
public PriceUpdateEvent {
Objects.requireNonNull(ticker, "ticker must not be null");
Objects.requireNonNull(price, "price must not be null");
Objects.requireNonNull(changePercent, "changePercent must not be null");
if (ticker.isBlank()) {
throw new IllegalArgumentException("ticker must not be blank");
}
if (price.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("price must be positive");
}
if (sequenceNumber < 0) {
throw new IllegalArgumentException("sequenceNumber cannot be negative");
}
if (timestamp == null) {
timestamp = Instant.now();
}
}
}
Step 3: Implement SseEmitter Broadcast Service
We implement `StockTickerStreamService` to maintain client connections, handle lifecycle callbacks, and broadcast events safely:
package com.webcodein.workshop.fullstack.service;
import com.webcodein.workshop.fullstack.model.PriceUpdateEvent;
import com.webcodein.workshop.fullstack.model.StreamStats;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
@Service
public class StockTickerStreamService {
public static final long DEFAULT_TIMEOUT_MS = 30 * 60 * 1000L;
private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();
private final AtomicLong eventCounter = new AtomicLong(0);
public SseEmitter subscribe(String clientId) {
SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT_MS);
emitters.put(clientId, emitter);
emitter.onCompletion(() -> emitters.remove(clientId));
emitter.onTimeout(() -> {
emitter.complete();
emitters.remove(clientId);
});
emitter.onError(e -> emitters.remove(clientId));
return emitter;
}
public int broadcastPriceUpdate(PriceUpdateEvent update) {
eventCounter.incrementAndGet();
int count = 0;
for (Map.Entry<String, SseEmitter> entry : emitters.entrySet()) {
try {
entry.getValue().send(SseEmitter.event()
.name("price_tick")
.id(String.valueOf(update.sequenceNumber()))
.data(update));
count++;
} catch (Exception e) {
emitters.remove(entry.getKey());
}
}
return count;
}
}
Step 4: Expose Server-Sent Events Endpoint
We expose a Spring MVC REST controller that produces `text/event-stream` payloads:
package com.webcodein.workshop.fullstack.controller;
import com.webcodein.workshop.fullstack.model.PriceUpdateEvent;
import com.webcodein.workshop.fullstack.model.StreamStats;
import com.webcodein.workshop.fullstack.service.StockTickerStreamService;
import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/stream")
public class StockStreamController {
private final StockTickerStreamService streamService;
public StockStreamController(StockTickerStreamService streamService) {
this.streamService = streamService;
}
@GetMapping(value = "/prices", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribePrices(@RequestParam(required = false) String clientId) {
String id = (clientId == null || clientId.isBlank()) ? UUID.randomUUID().toString() : clientId;
return streamService.subscribe(id);
}
@PostMapping("/publish")
public ResponseEntity<Map<String, Object>> publishUpdate(@Valid @RequestBody PriceUpdateEvent event) {
int delivered = streamService.broadcastPriceUpdate(event);
return ResponseEntity.ok(Map.of("ticker", event.ticker(), "delivered", delivered));
}
}
Comparative Real-Time Protocols Matrix
The table below provides a comprehensive architectural comparison between real-time data streaming options in enterprise web applications.
| Protocol Feature | Server-Sent Events (SSE) | WebSockets (RFC 6455) | HTTP Long Polling |
|---|---|---|---|
| Directionality | Unidirectional (Server to Client). | Full Duplex Bidirectional. | Simulated push via request-response cycle. |
| Transport Protocol | Standard HTTP/1.1 or HTTP/2. | TCP socket via HTTP Upgrade. | Standard HTTP/1.1. |
| Firewall & Proxy Compatibility | 100% compatible. Traverses all corporate proxies. | Frequently blocked or dropped by strict firewalls. | 100% compatible. |
| Reconnection Handling | Automatic with built-in Last-Event-ID replay. | Requires manual ping-pong heartbeat and backoff logic. | Manual polling loop in client code. |
| Concurrency Overhead (Virtual Threads) | Negligible. Less than 1 KB memory per emitter. | Moderate. Requires custom framing handlers. | Severe. High CPU churn and connection overhead. |
| Client UI Reactivity | Native EventSource binds directly to Angular Signals. | WebSocket client listener updates signals. | Repeated fetch promises resolve into state. |
Real-World Enterprise Case Studies
Let us review three production case studies demonstrating the effectiveness of Server-Sent Events with Angular Signals.
Case Study 1: Financial Equities Trading Terminal
A brokerage platform served eighty thousand institutional traders. Their desktop web terminal displayed real-time quotes, order books, and depth charts. Under their previous WebSocket implementation, frequent corporate proxy disconnections dropped live ticker updates, causing missed market orders.
The engineering team migrated their quote streaming to Spring Boot 4 SSE on Java 25 virtual threads combined with Angular 22 Zoneless Signals. Because SSE uses standard HTTP/2, corporate firewalls stopped terminating connections. Angular Signals updated price cells with zero dropped frames. Client memory usage dropped by sixty percent, and trading uptime reached 99.999% across global trading desks.
Case Study 2: Nationwide Delivery Fleet Live Telemetry
A national logistics carrier tracked forty thousand delivery vans. Dispatchers watched vehicle GPS coordinates update live on interactive maps. Their original system used HTTP polling every three seconds, generating over eight hundred million database queries daily and overloading read replicas.
The carrier implemented Spring Boot 4 SseEmitter streaming. When a van transmits its GPS coordinates to Kafka, the dispatcher gateway broadcasts the location to active dispatcher screens over an open SSE connection. Polling traffic disappeared entirely. Database CPU utilization plummeted from ninety percent to twelve percent, saving sixty thousand dollars in annual cloud infrastructure costs. For Kafka integration details, read our tutorial on Kafka Dead-Letter Topics and Non-Blocking Retries.
Case Study 3: Real-Time Collaborative Document Workspace
An enterprise knowledge management platform supported real-time collaborative editing. When multiple team members viewed a document, active cursor locations and lock states were pushed live. Under Zone.js, every cursor movement triggered change detection across the entire document, causing typing stutter for large documents.
The team adopted Angular 22 Signals with Zoneless change detection. Cursor coordinate updates streamed over SSE directly modified fine-grained cursor signals. The browser repainted only the floating cursor indicator without re-evaluating rich text paragraphs. Typing latency dropped to under five milliseconds, delivering a fluid editing experience.
Production Anti-Patterns and Best Practices
When developing push streaming architectures in production, avoid these four common mistakes.
Anti-Pattern 1: Leaking Abandoned SseEmitter Instances
If you fail to register `onCompletion` and `onTimeout` callbacks on your `SseEmitter`, the emitter reference remains in your server map forever. As clients connect and disconnect, the map leaks memory until the JVM throws an `OutOfMemoryError`.
The Fix: Always register cleanup callbacks immediately upon instantiating the emitter:
// CORRECT CLEANUP PATTERN
SseEmitter emitter = new SseEmitter(TIMEOUT_MS);
emitters.put(clientId, emitter);
emitter.onCompletion(() -> emitters.remove(clientId));
emitter.onTimeout(() -> emitters.remove(clientId));
emitter.onError(e -> emitters.remove(clientId));
Anti-Pattern 2: Forgetting Comment Heartbeats
Cloud load balancers (such as AWS ALB or Cloudflare) terminate idle TCP connections if no bytes are sent within sixty seconds. An SSE stream with infrequent updates will be repeatedly killed and reconnected.
The Fix: Configure a scheduled background task that sends lightweight comment heartbeats (`:keepalive\n\n`) every fifteen seconds across all active emitters.
Anti-Pattern 3: Emitting Massive JSON Blobs in Real-Time Streams
Pushing entire aggregate domain records on every tick exhausts client CPU parsing bandwidth. The browser thread spends all its time deserializing unnecessary JSON attributes.
The Fix: Emit concise, delta-only DTO records containing only the modified fields and a sequence number. Read more about low-latency data handling in Two-Level Cache with Redis and Caffeine in Spring Boot 4.
Anti-Pattern 4: Running Blocking Business Logic in Emitter Broadcast Loops
Iterating through thousands of emitters and performing a blocking database query inside the loop causes broadcast delays. Later clients receive price quotes seconds after earlier clients.
The Fix: Prepare the event payload once in memory before entering the loop. Transmit the pre-serialized event to emitters in parallel using Java 25 virtual threads.
Automated Testing with MockMvc and ArchUnit
We validate our SSE streaming service and controller with twelve automated unit, MockMvc slice, and ArchUnit architecture tests.
We test our controller using MockMvc to ensure that the SSE endpoint sets up an asynchronous request and establishes the stream successfully:
package com.webcodein.workshop.fullstack.controller;
import com.webcodein.workshop.fullstack.service.StockTickerStreamService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
class StockStreamControllerTest {
private MockMvc mockMvc;
private StockTickerStreamService streamService;
@BeforeEach
void setUp() {
streamService = new StockTickerStreamService();
StockStreamController controller = new StockStreamController(streamService);
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@Test
@DisplayName("GET /api/stream/prices should initiate SSE stream")
void shouldInitiateSseStream() throws Exception {
mockMvc.perform(get("/api/stream/prices?clientId=test-user-1"))
.andExpect(request().asyncStarted());
assertThat(streamService.getActiveEmitterCount()).isEqualTo(1);
}
}
We also enforce architectural isolation with ArchUnit 1.4.1:
package com.webcodein.workshop.fullstack.architecture;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.RestController;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
@AnalyzeClasses(packages = "com.webcodein.workshop.fullstack", importOptions = ImportOption.DoNotIncludeTests.class)
public class StreamArchitectureTest {
@ArchTest
static final ArchRule controllers_must_reside_in_controller_package =
classes().that().haveSimpleNameEndingWith("Controller")
.should().resideInAPackage("..controller..")
.andShould().beAnnotatedWith(RestController.class);
@ArchTest
static final ArchRule services_must_reside_in_service_package =
classes().that().haveSimpleNameEndingWith("Service")
.should().resideInAPackage("..service..")
.andShould().beAnnotatedWith(Service.class);
@ArchTest
static final ArchRule models_must_reside_in_model_package =
classes().that().resideInAPackage("..model..")
.should().beRecords();
}
Companion Workshop Repository
The complete, compilable source code for this guide is available on GitHub. It includes the complete Spring Boot 4 service, Docker configurations, and the full test suite:
GitHub: Server-Sent Events & Angular Signals Workshop Code
Clone the repository and run all tests locally using JDK 25:
git clone https://github.com/salahtobok/learn.webcodein.workshop.git
cd learn.webcodein.workshop/fullstack/spring-boot-4-sse-angular-22-signals
./mvnw clean test
Frequently Asked Questions
What are Server-Sent Events (SSE)?
Server-Sent Events is a standard web technology allowing a server to push real-time text updates to web clients over standard HTTP/1.1 or HTTP/2. Browsers listen to the stream using the native EventSource API with built-in automatic reconnection support.
When should I choose SSE over WebSockets?
Choose SSE when your communication is unidirectional (server pushing updates to the client), such as live financial tickers, news alerts, notifications, and CI logs. SSE is simpler, traverses corporate proxies effortlessly, and avoids custom WebSocket framing.
How do Java 25 Virtual Threads improve SseEmitter scalability?
Traditional platform threads consumed 1 MB of stack memory per connection, limiting servers to a few thousand open emitters. Java 25 Virtual Threads consume less than 1 KB each and unmount from carrier threads while idle, enabling 100,000+ concurrent connections on minimal heap memory.
How do Angular 22 Signals eliminate UI rendering lag?
Angular 22 Signals eliminate Zone.js top-down change detection sweeps. When an SSE message updates a signal, Angular repaints only the exact DOM node bound to that signal, maintaining 60 FPS even under high-frequency event streams.
How does SSE handle network disconnections?
The browser EventSource client reconnects automatically. When reconnecting, it transmits a Last-Event-ID HTTP header containing the last received message ID. The server can replay missed events from an in-memory buffer, preventing data loss.
