Streaming Report Progress to the Browser in a Multi-Instance Spring Boot App

@fakhrulnugrohoJuly 7, 2026

Exporting a big report is one of those features that looks trivial until it meets production. User clicks a button, the system builds a CSV, user gets a file. Easy, right?

Then the dataset grows to hundreds of thousands of rows and your pod runs out of memory. Then generation takes a minute and the HTTP request just hangs. Then you scale to multiple instances and your real-time progress bar mysteriously stops working for half your users.

This post is about how I handled all three β€” with heap-bounded pagination, producer-side progress throttling, and a fanout broadcast trick for pushing WebSocket updates across instances. The stack is Java + Spring Boot, but the ideas are language-agnostic.


🧱 The Setup: a serial-tracked inventory report

The running example is a report that shows up a lot in warehouse systems: serial-tracked inventory units. Every physical item has a unique serial (think IMEI, unit ID, whatever) and is tracked one by one β€” not "SKU X: 500 pcs" but 500 individual rows, each with its serial, bin location, status, and movement trail.

That detail matters: these reports get big and dense. One popular SKU can explode into tens of thousands of rows. Filter across a few warehouses and you're staring at hundreds of thousands. Great example, because every "big report" problem shows up loud and clear.

Three problems, none of them visible from the UI:

  1. The report can be huge. SELECT everything into a list and one request can eat your whole heap.
  2. Generation is slow. Seconds to minutes. If the HTTP request waits for it, the connection hangs and the user stares at a spinner with zero feedback.
  3. The interesting one: if you want a real-time progress bar over WebSocket, the worker generating the report might not be running on the same instance that holds the user's WebSocket connection.

Let's take them in the order of "how surprising they were."


🧠 The Non-Obvious Problem: your socket lives on the wrong node

Picture three instances behind a load balancer.

But node A doesn't have that WebSocket session β€” it's on node B. Node A can't just call session.sendMessage(...).

This is the classic stateful-connection-in-a-stateless-horizontal-world problem. The naive fix is sticky sessions (pin the user to one node), but sticky sessions are fragile β€” a restart or a scale event drops the session, and load gets lopsided.

The approach I like better: don't try to guess which node owns the socket. Broadcast to all of them, and let the one that actually owns it do the sending.

  Report worker (node A, @Async)
          β”‚  emit "progress 40%"
          β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  Message broker β€” FANOUT      β”‚   broadcast exchange, routing key = ""
  β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”˜
          β”‚         β”‚         β”‚        delivered to EVERY instance
    β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β” β”Œβ”€β”€β”€β–Όβ”€β”€β”€β”€β”€β” β”Œβ”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”
    β”‚ node A  β”‚ β”‚ node B  β”‚ β”‚ node C  β”‚   each node has its own queue:
    β”‚         β”‚ β”‚ βœ… has   β”‚ β”‚         β”‚   report-progress.<instanceId>
    β”‚ session?β”‚ β”‚ session!β”‚ β”‚ session?β”‚   (auto-delete, non-durable)
    β”‚  null β†’ β”‚ β”‚  send β†’ β”‚ β”‚  null β†’ β”‚
    β”‚  no-op  β”‚ β”‚ browser β”‚ β”‚  no-op  β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Each instance declares its own unique queue (report-progress.<instanceId>) bound to the same fanout exchange. Because it's fanout, every progress event lands on every node. Each node then runs:

JAVA
// runs on EVERY node
WebSocketSession session = this.sessions.get(clientId);
if (session != null && session.isOpen()) {
    session.sendMessage(new TextMessage(message));
}
// on nodes that don't hold this clientId: session == null β†’ do nothing

Only node B has that clientId in its ConcurrentHashMap, so only node B actually sends. Nodes A and C do a cheap no-op. You get sticky-session behavior without sticky sessions, and it survives scale up/down.

The per-instance queue is autoDelete = true, so when a node dies its queue disappears too β€” no orphaned queues piling up on the broker.

Transport note: this is raw WebSocket (TextWebSocketHandler), not STOMP. The user is correlated with a ?clientId=... query param at handshake, stored in a ConcurrentHashMap<clientId, WebSocketSession>. For "send one JSON progress frame to one client," STOMP is overkill.


πŸ’Ύ Keeping the Export Heap-Bounded

The wrong way (and, sadly, the common way):

JAVA
List<SerialUnit> all = repository.findAll(spec);        // ← whole result set in memory
List<ReportRow> rows = all.stream().map(...).toList();   // ← now duplicated again
writer.writeAll(rows);

For a 300k-unit report, that pins hundreds of thousands of entities plus hundreds of thousands of DTOs in the heap at once. A couple of concurrent requests and the node OOMs.

The fix: paginate and write-then-discard. Page size 100, a do/while loop over Pageable.nextPageable().

JAVA
private static final int REPORT_PAGE_SIZE = 100;

// ... inside the CSV-writing callback:
csvWriter.writeNext(SerialUnitReportRow.headers());

Pageable pageable = PageRequest.of(0, REPORT_PAGE_SIZE,
                                   Sort.by(Sort.Direction.DESC, "createdAt"));
Page<SerialUnit> page;
do {
    page = serialUnitRepository.findAll(SerialUnitSpecification.filter(filter), pageable);

    for (SerialUnit unit : page.getContent()) {
        // one serial unit can expand into several rows (e.g. its movement trail)
        for (SerialUnitReportRow row : buildReportRows(unit)) {
            csvWriter.writeNext(row.toCsvRow());          // written β†’ this row can be GC'd
            payload.incrementIndex();
            progressPublisher.publish(payload, throttle, false);   // progress (throttled)
        }
    }
    pageable = page.nextPageable();
} while (page.hasNext());

At any moment the heap holds ≀100 entities plus their derived DTOs. Memory cost is O(page size), not O(total rows) β€” whether the report is 1,000 rows or 1,000,000, heap usage is flat.

About object storage: no, this isn't a "streaming pipe"

Let's be honest and not over-claim. The CSV is written to a local temp file first, then uploaded whole to object storage:

JAVA
public UploadResult writeCsvAndUpload(String prefix, UploadOptions opts,
                                      CsvWriterCallback callback) throws IOException {
    Path tempFile = Files.createTempFile(prefix, ".csv");
    try (BufferedWriter writer = Files.newBufferedWriter(tempFile, StandardCharsets.UTF_8);
         CSVWriter csvWriter = new CSVWriter(writer)) {

        callback.write(csvWriter);      // ← the paginated loop above runs here
        writer.flush();

        UploadResult result = new UploadResult();
        result.setKey(uploadToStorage(tempFile, opts));   // upload AFTER the file is complete
        return result;
    } finally {
        Files.deleteIfExists(tempFile);   // ← deterministic cleanup, no matter what
    }
}

So the shape is: heap-bounded (via pagination), but the complete file is buffered on disk and uploaded in a single putObject. The storage SDK reads from a Path, so the upload itself doesn't hold the file in heap. It is not multipart-streaming-as-you-go, and I won't call it that.

Small thing people forget: finally { Files.deleteIfExists(tempFile); }. Without it, every report leaves a temp file behind until the disk fills. Deterministic cleanup is part of "done," not an afterthought.


⏱️ Throttling Progress So You Don't Flood Everything

Fire and forget

The controller doesn't wait for the report. It builds a job descriptor and returns immediately:

JAVA
@GetMapping("/report")
public DownloadJob downloadReport(@ModelAttribute SerialUnitFilter filter) {
    String fileName = "INVENTORY SERIAL REPORT " + newId() + ".csv";
    DownloadJob job = DownloadJob.builder()
            .downloadId(filter.getDownloadId())   // job id (from client)
            .clientId(filter.getClientId())        // WebSocket routing key
            .fileName(fileName)
            .build();

    reportService.downloadReport(filter, job);     // @Async β€” fire and forget
    return job;                                    // returns while the report is still running
}

The worker is @Async("reportExecutor"). The client immediately has downloadId and clientId β€” enough to attach a WebSocket listener and/or poll for status.

Throttle: from a flood to a useful trickle

The paginated loop calls publish(...) for every single row. A 50k-row report means 50k broker messages, 50k DB upserts, and 50k WebSocket frames. That hammers the broker and makes the browser's progress bar lag from too many updates.

The fix is a tiny ProgressThrottle β€” a gate on the producer side, before the message ever hits the broker:

JAVA
private static final int EARLY_INDEX_THRESHOLD = 10;
private long MIN_INTERVAL_MS = 1_000;
private long lastPublishedAt = 0L;
private boolean hasPublished = false;

public boolean shouldPublish(int index, int totalData, double pct, int statusCode, boolean force) {
    if (force || statusCode == 200 || statusCode >= 400) { markPublished(); return true; } // (1) terminal / forced
    if (!hasPublished)                                    { markPublished(); return true; } // (2) very first event
    if (index <= EARLY_INDEX_THRESHOLD)                   { markPublished(); return true; } // (3) first 10 rows
    if (System.currentTimeMillis() - lastPublishedAt >= MIN_INTERVAL_MS) {
        markPublished(); return true;                                                       // (4) interval reached
    }
    return false;   // otherwise: silently drop
}

Four rules, in priority order, shaped exactly around what a progress bar actually needs:

  1. Terminal states always pass β€” success (200), error (β‰₯400), or force. The user is guaranteed to see the "done" or "failed" frame with the download link. This one must never be dropped.
  2. The first event always passes β€” the bar jumps off zero right away instead of sitting still for a few seconds.
  3. The first 10 rows always pass β€” early feedback feels snappy even before the interval elapses.
  4. Everything else is rate-limited β€” at least 1 second between updates (for really long reports, bump it to 5s).

That 50k-row report goes from 50k frames to: 1 start frame + 10 early frames + ~1 frame per interval + 1 terminal frame. Hundreds of times fewer, with zero loss in responsiveness.

One honest confession, for technical integrity: the throttle itself is not thread-safe (plain long/boolean fields, and shouldPublish mutates state). That's fine in a single-threaded worker. But there's one path β€” an export that fans out per-page enrichment onto a thread pool to speed up lookups β€” where thread-safety is delegated to the call site with a synchronized (progressLock) wrapping the payload mutation + publish. It's a deliberate choice, not an oversight β€” but if someone reuses the class without knowing that contract, it's a bug waiting to happen. (Safer alternative: just make the method synchronized.)


πŸ”— Following One Progress Event End-to-End

Worker thread
   β”‚  progressPublisher.publish(payload, throttle, force)
   β–Ό
ProgressPublisher
   β”‚  1. throttle.shouldPublish(...)  ──false──▢ stop (dropped)
   β”‚  2. persistWithLock(payload)     ── upsert DownloadHistory (best-effort)
   β”‚  3. webSocketPool.send(payload)
   β–Ό
WebSocketPool  β†’  broker.publish("report-progress", json)
   β–Ό
Message broker FANOUT  (routing key "", persistent)
   β–Ό  (broadcast to every instance)
Broadcast consumer (dedicated thread, prefetch=1, manual ack)
   β”‚  per-instance queue: report-progress.<instanceId>
   β–Ό
WebSocketPool.send(clientId, json)  β†’ only the node that owns the socket sends
   β–Ό
Browser  (raw WebSocket TextMessage, JSON)

Step 2 β€” persistWithLock β€” is worth a closer look. Besides pushing to the socket, each update is also upserted into a DownloadHistory table as durable job status, under a distributed lock:

JAVA
Lock lock = distributedLock.get("lock:download-history:" + downloadId);
boolean acquired = lock.tryLock(3, 10, TimeUnit.SECONDS);   // wait 3s, lease 10s
if (!acquired) {
    log.warn("Skip download_history upsert, lock busy...");
    return;                          // skip the DB write, BUT still send the socket frame
}
downloadHistoryService.upsertFromPayload(payload);

The lock serializes upserts for the same downloadId (relevant for that multi-threaded export). If the lock is busy, we skip the DB write but still send the socket β€” the socket is the primary channel; DownloadHistory is the pollable fallback. Right priority: don't let contention on a status table block the real-time updates.

The published states are clean and explicit:

Since totalData grows page by page (we don't know the total up front without an expensive COUNT(*)), the percentage is an estimate that can jump β€” and only complete() pins it to exactly 100%. A deliberate trade-off: skip the up-front COUNT(*) and accept a slightly less smooth percentage.


πŸŽ›οΈ The Senior Move: Domain-Aware Rejection Policy

This is my favorite part, because it's not in any tutorial. Reports aren't the only async work β€” there's also import (which writes stock). Both are heavy, but the consequences differ, so the executors differ:

JAVA
// reportExecutor β€” reports are read-only; dropping one is fine
executor.setCorePoolSize(3);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(50);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
// full β†’ reject β†’ user just retries. Nothing gets corrupted.

// importExecutor β€” imports CHANGE STOCK; must never be dropped
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// full β†’ the calling thread runs it β†’ backpressure, no work lost.

The reasoning: rejecting a report is harmless β€” the user clicks again, no consequences. Rejecting a stock movement is a disaster β€” stock might not get updated and the warehouse drifts out of sync. So reports use AbortPolicy (fail fast, protect the latency of other work), and imports use CallerRunsPolicy (apply backpressure, never lose work).

Choosing a rejection policy based on domain consequence rather than a copy-pasted default β€” that's the difference between "grabbed a thread-pool config off Stack Overflow" and actually understanding what you're protecting.

Both executors also carry decorators that propagate request context (requestId/userId for tracing) and the security context onto the worker thread, so async work still knows who asked for it.


⚠️ Error Handling (and an Honest Inconsistency)

The correct path wraps the whole export and guarantees a terminal frame:

JAVA
try {
    // ... paginated loop, write CSV, upload storage ...
    sendProgress(payload.complete(fileLink), throttle, true);   // force=true
} catch (Exception e) {
    log.error(e.getMessage());
    sendProgress(payload.fail(e.getMessage()), throttle, true); // statusCode 500, force=true
} finally {
    payload.reset();
}

Because fail() sets statusCode = 500 and force = true, the throttle always lets it through (rule #1). The browser is guaranteed to get the error state, and DownloadHistory.failedAt gets stamped. The partial temp file is discarded; no storage link is set.

But here's the honest lesson: not every path is consistent. There's one export (that multi-threaded one) that only has a try (ExecutorService ...) with no catch that publishes fail(). If it throws mid-run, the exception escapes the @Async method and gets swallowed by the framework's async handler β€” the browser gets no error frame, and DownloadHistory is stuck at 101 forever.

I'm writing this down instead of claiming the error handling is uniform. Finding an asymmetry like this in your own code, and knowing it's a bug, is worth more than pretending everything's perfect. (The fix is trivial: wrap it in the same try/catch as the other paths.)


✨ Takeaways

If you're building something similar, here's the distilled decision list:

  1. For big exports, paginate and write-then-discard. O(page size) beats O(total rows) β€” it's the only way an export stays safe as the data grows. For serial-tracked data, where one SKU can be tens of thousands of rows, this isn't an optimization β€” it's a requirement.
  2. For stateful connections on a horizontal fleet, don't guess the node. Broadcast. Fanout + per-instance queues give you sticky-session behavior without the fragility. Irrelevant nodes just no-op.
  3. Throttle progress on the producer side, with rules that mirror what a progress bar needs: instant start, snappy early feedback, a low-rate trickle in the middle, and a guaranteed terminal frame.
  4. Pick your rejection policy from domain consequence, not the default. Dropping a report is cheap; dropping a stock movement is expensive.
  5. Cleanup and error frames are part of "done." finally { deleteIfExists } and a guaranteed fail() aren't polish β€” they're what separates a feature that works in a demo from one that works at 2 a.m. in production.

And a word on mindset: every time you're tempted to write "streams to storage," "thread-safe," or "uniform error handling" β€” ask whether it's actually true. In this system the data path is buffer-then-upload (not a streaming pipe), the throttle is not thread-safe (guarded at the call site), and one export path doesn't publish errors. Naming those things honestly isn't a weakness in the write-up β€” it's exactly what makes it trustworthy.


πŸ”š Conclusion

Big-report export looks like a CRUD chore, but under real load it turns into a distributed-systems problem: bounded memory, backpressure, and getting a message to the one node out of many that holds your user's socket. None of the individual pieces are exotic β€” pagination, a fanout exchange, a small throttle, a well-chosen rejection policy β€” but wiring them together with honesty about the trade-offs is where the actual engineering lives.

Happy coding πŸš€


Stack: Java 21, Spring Boot 3.3, PostgreSQL, message broker (raw AMQP client), distributed lock (Redis), S3-compatible object storage, raw Spring WebSocket, OpenCSV.

Found this useful, or spotted something you'd do differently? I'd love to hear it β€” reach me at fakhrulnugroho15@gmail.com Β· GitHub @fakhrulnugroho Β· LinkedIn @fakhrulnugroho.