Stream Real‑Time File Import Progress with Spring WebFlux and SSE
This article shows how to replace inefficient front‑end polling with a Spring WebFlux server‑sent events (SSE) solution that pushes file‑import progress to the browser in real time, providing complete code for the progress service, import logic, controller, and a vanilla‑JS front‑end example, plus deployment tips.
As part of an asynchronous programming series, this article demonstrates how to use Spring WebFlux together with Server‑Sent Events (SSE) to push real‑time progress updates for long‑running file import tasks, eliminating the need for frequent front‑end polling.
Solution Comparison
Traditional approach: after the front‑end uploads a file, the back‑end starts an asynchronous import task and stores its status. The front‑end repeatedly polls a status endpoint (e.g., every 2 seconds) to update a progress bar. This wastes resources, bandwidth, and introduces noticeable latency.
Server‑push approach: using Spring WebFlux to create an SSE stream, the back‑end actively pushes progress events to the front‑end, which updates the UI instantly. This provides millisecond‑level updates and a smoother user experience, though it requires familiarity with reactive programming.
Design Idea
The complete single‑node implementation consists of a progress service, an import service, a controller, and a vanilla JavaScript front‑end.
1. Progress Service (ImportProgressService)
@Service
public class ImportProgressService {
private final ConcurrentHashMap<String, AtomicInteger> progressMap = new ConcurrentHashMap<>();
public void initProgress(String taskId) {
progressMap.put(taskId, new AtomicInteger(0));
}
public void updateProgress(String taskId, int percent) {
progressMap.computeIfAbsent(taskId, k -> new AtomicInteger()).set(percent);
}
public int getProgress(String taskId) {
return progressMap.getOrDefault(taskId, new AtomicInteger(0)).get();
}
public void clear(String taskId) {
progressMap.remove(taskId);
}
}2. Import Service (ImportService)
@Service
@RequiredArgsConstructor
public class ImportService {
private final ImportProgressService progressService;
public void importData(MultipartFile file, String taskId) {
progressService.initProgress(taskId);
try (InputStream in = file.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(in))) {
List<String> lines = reader.lines().collect(Collectors.toList());
int total = lines.size();
for (int i = 0; i < total; i++) {
String line = lines.get(i);
Thread.sleep(100); // simulate processing
int percent = (int) ((i + 1) * 100.0 / total);
progressService.updateProgress(taskId, percent);
}
progressService.updateProgress(taskId, 100);
} catch (Exception e) {
progressService.updateProgress(taskId, -1);
throw new RuntimeException("Import failed", e);
}
}
}3. Controller (ImportController)
@RestController
@RequiredArgsConstructor
public class ImportController {
private final ImportService importService;
private final ImportProgressService progressService;
@PostMapping("/import")
public Mono<ResponseEntity<String>> upload(@RequestParam("file") MultipartFile file) {
String taskId = UUID.randomUUID().toString();
Mono.fromRunnable(() -> importService.importData(file, taskId))
.subscribeOn(Schedulers.boundedElastic())
.subscribe();
return Mono.just(ResponseEntity.ok(taskId));
}
@GetMapping(value = "/import/progress/{taskId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> getProgress(@PathVariable String taskId) {
return Flux.interval(Duration.ofMillis(500))
.map(tick -> progressService.getProgress(taskId))
.map(progress -> {
if (progress == -1) return "Import failed";
if (progress >= 100) return "Import completed";
return "Current progress: " + progress + "%";
})
.takeUntil(msg -> msg.contains("completed") || msg.contains("failed"));
}
}4. Front‑end Example (Vanilla JS)
<input type="file" id="fileInput">
<button onclick="startImport()">Start Import</button>
<div id="progressText"></div>
<script>
function startImport() {
const file = document.getElementById('fileInput').files[0];
const formData = new FormData();
formData.append('file', file);
fetch('/import', { method: 'POST', body: formData })
.then(res => res.text())
.then(taskId => {
const source = new EventSource('/import/progress/' + taskId);
source.onmessage = function(event) {
document.getElementById('progressText').innerText = event.data;
if (event.data.includes('completed') || event.data.includes('failed')) {
source.close();
}
};
});
}
</script>Further Suggestions
In a distributed environment, replace the in‑memory progressMap with Redis for unified progress state management.
Add error‑handling fields to the progress response to convey failure reasons.
After successful import, trigger additional notifications via SSE, email, or in‑app messages.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Lin is Dream
Sharing Java developer knowledge, practical articles, and continuous insights into computer engineering.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
