Streaming Server‑Sent Events with Spring Boot WebFlux and a JavaScript EventSource Client
Streaming updates from a Spring Boot WebFlux backend too a browser can be implemented with Server‑Sent Events (SSE). SSE uses a long‑lived HTTP connection where the server pushes events to the client, while the client remains read‑only. Compared with WebSocket, SSE is one‑directional (server → client), auto‑reconnects, travels over standard HTTP, and is ideal for feeds, notifications, and login/scan callbacks. When the browser and API are on different origins, enable CORS on the server.
CORS configuraton (WebFlux)
package com.example.sse.config;
import java.util.Collections;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsWebFilter;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
@Configuration
public class CorsSetup {
@Bean
public CorsWebFilter corsWebFilter() {
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOrigins(Collections.singletonList("*"));
cfg.addAllowedHeader("*");
cfg.addAllowedMethod("*");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return new CorsWebFilter(source);
}
}
Maven configurasion
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>webflux-sse-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.2.RELEASE</version>
</parent>
<properties>
<java.version>1.8</java.version>
<junit-jupiter.version>5.3.2</junit-jupiter.version>
</properties>
<dependencies>
<!-- Reactive HTTP stack -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- Server-side templates -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit-jupiter.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.0</version>
</plugin>
</plugins>
</build>
</project>
Domain model
package com.example.sse.model;
public class Comment {
private String author;
private String content;
private String createdAt;
public Comment() {}
public Comment(String author, String content, String createdAt) {
this.author = author;
this.content = content;
this.createdAt = createdAt;
}
public String getAuthor() { return author; }
public void setAuthor(String author) { this.author = author; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getCreatedAt() { return createdAt; }
public void setCreatedAt(String createdAt) { this.createdAt = createdAt; }
}
Reactive feed contract and implementation
package com.example.sse.repository;
import com.example.sse.model.Comment;
import reactor.core.publisher.Flux;
public interface CommentFeed {
Flux<Comment> stream();
}
package com.example.sse.repository;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
import org.springframework.stereotype.Repository;
import com.example.sse.model.Comment;
import reactor.core.publisher.Flux;
@Repository
public class TimedCommentFeed implements CommentFeed {
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss");
private final Random random = new Random();
private final List<String> authors = Arrays.asList(
"Alex", "Brooke", "Casey", "Dylan", "Emery",
"Finley", "Gray", "Hayden", "Indigo", "Jules");
private final List<String> messages = Arrays.asList(
"Nice!", "Agreed", "Interesting", "Indeed", "Hello all", "Great work");
@Override
public Flux<Comment> stream() {
return Flux.interval(Duration.ofSeconds(1))
.onBackpressureDrop()
.map(tick -> randomComment());
}
private Comment randomComment() {
String author = authors.get(random.nextInt(authors.size()));
String content = messages.get(random.nextInt(messages.size()));
String ts = FMT.format(LocalDateTime.now());
return new Comment(author, content, ts);
}
}
HTTP endpoints (WebFlux controller)
package com.example.sse.web;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.example.sse.model.Comment;
import com.example.sse.repository.CommentFeed;
import reactor.core.publisher.Flux;
@RestController
@RequestMapping("/api/comments")
public class CommentStreamController {
private final CommentFeed feed;
public CommentStreamController(CommentFeed feed) {
this.feed = feed;
}
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<Comment> stream() {
return feed.stream();
}
}
Server-side view conrtoller
package com.example.sse.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class PageController {
@GetMapping({"/", "/index"})
public String home() {
return "index";
}
}
Application bootstrap and configuration
package com.example.sse;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SseApplication {
public static void main(String[] args) {
SpringApplication.run(SseApplication.class, args);
}
}
appilcation.properties
logging.level.org.springframework.web=INFO
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
spring.thymeleaf.mode=HTML5
spring.thymeleaf.encoding=UTF-8
spring.thymeleaf.cache=false
Thymeleaf template (resources/templates/index.html)
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/css/bootstrap.min.css" />
<link rel="stylesheet" href="/css/main.css" />
<title>WebFlux SSE</title>
</head>
<body>
<div class="container">
<div id="title"><h1>WebFlux Server‑Sent Events</h1></div>
<table id="commentTable" class="table table-striped">
<thead>
<tr>
<th width="15%">Author</th>
<th width="55%">Message</th>
<th width="30%">Time</th>
</tr>
</thead>
<tbody>
<tr data-th-each="c : ${comments}">
<td>[[${c.author}]]</td>
<td>[[${c.content}]]</td>
<td>[[${c.createdAt}]]</td>
</tr>
</tbody>
</table>
</div>
<script src="/js/main.js"></script>
</body>
</html>
Client-side EventSource (resources/static/js/main.js)
(function () {
const table = document.getElementById("commentTable");
let es;
function render(comment) {
const row = table.tBodies[0].insertRow(0);
const c0 = row.insertCell(0);
const c1 = row.insertCell(1);
const c2 = row.insertCell(2);
c0.className = "author-style";
c1.className = "text";
c2.className = "date";
c0.textContent = comment.author;
c1.textContent = comment.content;
c2.textContent = comment.createdAt;
}
function start() {
es = new EventSource("/api/comments/stream");
es.addEventListener("message", (evt) => {
try {
const data = JSON.parse(evt.data);
render(data);
} catch (e) {
console.error("Invalid event payload", e);
}
});
es.addEventListener("error", () => {
// EventSource will attempt to reconnect automatically
// Close if the connection is permanently down
if (es && es.readyState === EventSource.CLOSED) {
es.close();
}
});
}
function stop() {
if (es) es.close();
}
window.addEventListener("load", start);
window.addEventListener("beforeunload", stop);
})();
Minimal CSS (resources/static/css/main.css)
#title { margin: 40px 0; }
Unit test with WebTestClient
package com.example.sse;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import com.example.sse.model.Comment;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class SseApplicationTest {
@Autowired
private WebTestClient client;
@Test
void shouldStreamComments() {
List<Comment> firstThree = client.get()
.uri("/api/comments/stream")
.accept(MediaType.TEXT_EVENT_STREAM)
.exchange()
.expectStatus().isOk()
.returnResult(Comment.class)
.getResponseBody()
.take(3)
.collectList()
.block();
assertEquals(3, firstThree.size());
}
}
HTTP endpoints
- GET / → renders the Thymeleaf view
- GET /api/comments/stream → SSE stream producing MediaType text/event-stream