Progress Users Can Trust for Long-Running Enterprise Jobs
A practical Quarkus architecture that keeps job state durable, survives reconnects and restarts, and exposes operational limits before deployment.
Last year I built a real-time file upload progress tracker with Quarkus and SSE. It split a file into browser-side chunks, counted bytes in an in-memory map, and pushed each update to an open SSE connection.
That solves one problem: the user can see bytes crossing the network.
It does not solve the more common enterprise problem. The file reaches the server, the bar says 100%, and then the real work begins. The application validates 200,000 rows, resolves references, writes batches, calls another system, or generates a report. The browser has no idea how long that work will take. A green upload bar quickly becomes a lie.
I want to fix that in this follow-up.
We will build an invoice import on Quarkus 3.37.2 and Java 25. The browser shows two separate clocks:
Transfer progress measures bytes from the browser to the HTTP endpoint.
Processing progress measures durable business work after Quarkus accepts the file.
The job lives in PostgreSQL. A basic Quarkus Scheduler worker claims it. Server-Sent Events deliver snapshots, but the connection owns no state. Reload the page halfway through and the UI reconstructs itself from the database. Cancel the job and the worker stops between batches. Restart the application with the same staging volume and startup recovery puts interrupted work back into the queue.
That is a more useful progress bar because it models the work instead of decorating the request.
What we are building
The complete companion application is in the honest-progress directory. It includes a 60-row sample CSV and a deliberately visible processing delay so you can watch the state changes.
The flow looks like this:
There are four REST operations:
POST /api/importsaccepts the multipart file and returns202 Accepted.GET /api/imports/{id}returns one authoritative snapshot.GET /api/imports/{id}/eventsstreams changed snapshots as SSE.DELETE /api/imports/{id}requests cooperative cancellation.
Notice what is absent: there is no active SSE sink registry and no in-memory progress map. An SSE connection may disappear at any time without changing the job.
Prerequisites
You need:
Java 25
Podman with its Docker-compatible socket enabled
A current Quarkus CLI, or Maven if you prefer to create the project directly
About ☕️☕️
PostgreSQL comes from Quarkus Dev Services. With Podman running, there is no database setup for dev mode or tests.
Create the application or start from my Github repository:
quarkus create app com.themainthread.progress:honest-progress \
--extension=rest-jackson,hibernate-orm-panache,jdbc-postgresql,scheduler,hibernate-validator \
--java=25 \
--no-code
cd honest-progressI am using the basic quarkus-scheduler extension. It is enough for one application instance and keeps the job lifecycle visible. I will cover the clustered challenges later.
Model work, not elapsed time
A progress model needs more than a percentage. It needs a state, a phase, completed units, total units, a human-readable message, and an optimistic-lock version.
My sample uses these states:
package com.themainthread.progress.domain;
public enum JobState {
QUEUED,
RUNNING,
SUCCEEDED,
FAILED,
CANCELLED;
public boolean terminal() {
return this == SUCCEEDED || this == FAILED || this == CANCELLED;
}
}And these phases:
package com.themainthread.progress.domain;
public enum JobPhase {
QUEUED,
VALIDATING,
IMPORTING,
FINALIZING,
COMPLETE
}RUNNING tells an operator that a worker owns the job. VALIDATING tells the user what that worker is doing.
Only the import phase has a decent denominator. During validation, the application does not yet know how many valid rows exist. During finalization, a database transaction may take 50 ms or five seconds, but “73% committed” is not a real measurement. Those phases use an indeterminate progress bar.
The API record calculates a percentage only when it can produce a number:
private static Integer percentage(ImportJob job) {
if (job.jobState == JobState.SUCCEEDED) {
return 100;
}
if (job.totalUnits == 0
|| job.jobPhase == JobPhase.VALIDATING
|| job.jobPhase == JobPhase.FINALIZING) {
return null;
}
return (int) Math.min(100,
job.completedUnits * 100 / job.totalUnits);
}Returning null is not a missing feature. It is a statement that we know the phase but cannot calculate meaningful completion inside it.
The ImportJob entity persists this state with timestamps, a cancellation flag, the staged file path, and @Version:
package com.themainthread.progress.domain;
import java.time.Instant;
import java.util.UUID;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
@Entity
@Table(name = "import_job")
public class ImportJob {
@Id
public UUID id;
@Column(name = "original_file_name", nullable = false, length = 255)
public String originalFileName;
@Column(name = "stored_path", nullable = false, length = 1024)
public String storedPath;
@Column(name = "file_size", nullable = false)
public long fileSize;
@Enumerated(EnumType.STRING)
@Column(name = "job_state", nullable = false, length = 32)
public JobState jobState;
@Enumerated(EnumType.STRING)
@Column(name = "job_phase", nullable = false, length = 32)
public JobPhase jobPhase;
@Column(name = "completed_units", nullable = false)
public long completedUnits;
@Column(name = "total_units", nullable = false)
public long totalUnits;
@Column(name = "published_count", nullable = false)
public long publishedCount;
@Column(name = "cancellation_requested", nullable = false)
public boolean cancellationRequested;
@Column(nullable = false, length = 500)
public String message;
@Column(name = "error_message", length = 2000)
public String errorMessage;
@Column(name = "created_at", nullable = false)
public Instant createdAt;
@Column(name = "updated_at", nullable = false)
public Instant updatedAt;
@Column(name = "finished_at")
public Instant finishedAt;
@Version
public long version;
}The important field for streaming is version. Every committed change increments it, so an SSE endpoint can suppress identical snapshots without maintaining its own business state.
Accept the upload, then return a job
Quarkus REST supports multipart form data through FileUpload. The upload endpoint moves Quarkus’ temporary file into application-controlled staging, creates the job, and returns immediately.
package com.themainthread.progress.api;
import java.net.URI;
import java.util.UUID;
import com.themainthread.progress.domain.ImportProgress;
import com.themainthread.progress.job.ProgressStreamService;
import io.smallrye.common.annotation.Blocking;
import io.smallrye.mutiny.Multi;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import jakarta.ws.rs.sse.OutboundSseEvent;
import jakarta.ws.rs.sse.Sse;
import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.multipart.FileUpload;
@Path("/api/imports")
@Produces(MediaType.APPLICATION_JSON)
public class ImportResource {
private final ImportJobService jobs;
private final ProgressStreamService streams;
private final Sse sse;
public ImportResource(ImportJobService jobs,
ProgressStreamService streams, Sse sse) {
this.jobs = jobs;
this.streams = streams;
this.sse = sse;
}
@POST
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Response create(@NotNull @RestForm("file") FileUpload file,
@Context UriInfo uriInfo) {
ImportProgress progress = jobs.create(file);
URI location = uriInfo.getAbsolutePathBuilder()
.path(progress.id().toString())
.build();
return Response.accepted(progress).location(location).build();
}
@GET
@Path("/{id}")
public ImportProgress get(@PathParam("id") UUID id) {
return jobs.snapshot(id);
}
@DELETE
@Path("/{id}")
public Response cancel(@PathParam("id") UUID id) {
return Response.accepted(jobs.cancel(id)).build();
}
@GET
@Path("/{id}/events")
@Produces(MediaType.SERVER_SENT_EVENTS)
@Blocking
public Multi<OutboundSseEvent> events(@PathParam("id") UUID id) {
ImportProgress initial = jobs.snapshot(id);
return streams.stream(id, initial).map(this::event);
}
private OutboundSseEvent event(ImportProgress progress) {
return sse.newEventBuilder()
.id(Long.toString(progress.version()))
.name("progress")
.reconnectDelay(2000)
.mediaType(MediaType.APPLICATION_JSON_TYPE)
.data(ImportProgress.class, progress)
.build();
}
}202 Accepted means that the transfer completed and the server accepted a different resource, the import job, for asynchronous processing. The Location header tells the client where to find it.
The limits must match the endpoint. Quarkus applies a small default limit to multipart form attributes, so raising only the overall body limit is not enough for a large file field:
quarkus.http.limits.max-body-size=26M
quarkus.http.limits.max-form-attribute-size=25M
progress.staging-directory=target/staged-uploads
progress.stream-interval=500ms
progress.processing-delay=150ms
progress.batch-size=5
progress.scheduler-interval=1s
%dev.quarkus.hibernate-orm.schema-management.strategy=drop-and-create
%test.quarkus.hibernate-orm.schema-management.strategy=drop-and-create
%prod.quarkus.hibernate-orm.schema-management.strategy=validate
%test.quarkus.scheduler.enabled=false
%prod.progress.staging-directory=/var/lib/honest-progress/staged-uploads
%prod.progress.processing-delay=0sThe delay exists only to make a 60-row demo observable. The production profile disables it.
Do not trust an uploaded file name as a path. The sample keeps only Path.of(name).getFileName(), generates the stored name from the job UUID, accepts .csv, and moves the file before the request ends. Quarkus owns the multipart temporary file only for the request lifecycle.
Let the scheduler claim durable work
The worker is small and boring as usually:
package com.themainthread.progress.job;
import java.util.List;
import com.themainthread.progress.persistence.JobStore;
import com.themainthread.progress.persistence.JobStore.RecoveryAction;
import com.themainthread.progress.storage.FileStorage;
import io.quarkus.runtime.StartupEvent;
import io.quarkus.scheduler.Scheduled;
import io.quarkus.scheduler.Scheduled.ConcurrentExecution;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
@ApplicationScoped
public class ImportJobScheduler {
private final JobStore store;
private final ImportJobProcessor processor;
private final FileStorage fileStorage;
public ImportJobScheduler(JobStore store,
ImportJobProcessor processor, FileStorage fileStorage) {
this.store = store;
this.processor = processor;
this.fileStorage = fileStorage;
}
void recover(@Observes StartupEvent event) {
List<RecoveryAction> actions = store.recoverInterrupted();
for (RecoveryAction action : actions) {
if (action.deleteFile()) {
fileStorage.deleteQuietly(action.storedPath());
}
}
}
@Scheduled(identity = "invoice-import-runner",
every = "${progress.scheduler-interval}",
concurrentExecution = ConcurrentExecution.SKIP)
public void runNext() {
store.claimNext().ifPresent(processor::process);
}
}The Quarkus Scheduler supports property expressions and skipped concurrent execution. SKIP prevents another trigger from entering this method while the current import is still running in this application instance.
claimNext() locks the oldest queued row and changes it to RUNNING in one transaction. The processor then follows this order:
Validate the complete CSV and count its rows.
Change the phase to
IMPORTINGand settotalUnits.Persist small batches with
published=false.Check the cancellation flag between batches.
Change the phase to
FINALIZING.Publish every staged invoice and mark the job
SUCCEEDEDin one transaction.
Validation before import avoids predictable partial failures. The unpublished flag handles failures that happen later. If processing fails or the user cancels, the application deletes the staged invoice rows. If the application stops after committing a batch, startup recovery deletes unpublished rows and requeues the job before the scheduler continues.
This is not one giant transaction. A 200,000-row import should not hold locks and a persistence context for the entire job. Small transactions make progress durable and cancellation responsive. The publication flag keeps those intermediate commits invisible to downstream business queries.
Cooperative cancellation is equally deliberate. DELETE does not interrupt a Java thread in the middle of a database operation. It sets cancellationRequested=true. The worker finishes its current batch, observes the flag, cleans up, and ends in CANCELLED. That gives the code a safe boundary instead of pretending arbitrary interruption is harmless.
Stream database snapshots, not process memory
SSE is still the right fit for the second clock. Progress moves from server to browser, the browser API reconnects automatically, and ordinary HTTP remains available for create, read, and cancel commands.
The stream emits the current snapshot immediately, then checks for version changes on a worker thread:
package com.themainthread.progress.job;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import com.themainthread.progress.config.ProgressConfig;
import com.themainthread.progress.domain.ImportProgress;
import com.themainthread.progress.persistence.JobStore;
import io.smallrye.mutiny.Multi;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.infrastructure.Infrastructure;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class ProgressStreamService {
private final JobStore store;
private final ProgressConfig config;
public ProgressStreamService(JobStore store, ProgressConfig config) {
this.store = store;
this.config = config;
}
public Multi<ImportProgress> stream(UUID id, ImportProgress initial) {
AtomicBoolean terminalSeen = new AtomicBoolean();
AtomicLong lastVersion = new AtomicLong(Long.MIN_VALUE);
Multi<ImportProgress> changes = Multi.createFrom().ticks()
.every(config.streamInterval())
.onItem().transformToUniAndConcatenate(
ignored -> snapshotAsync(id));
return Multi.createBy().concatenating()
.streams(Multi.createFrom().item(initial), changes)
.select().first(progress ->
!terminalSeen.getAndSet(progress.terminal()))
.select().where(progress ->
progress.version()
!= lastVersion.getAndSet(progress.version()));
}
private Uni<ImportProgress> snapshotAsync(UUID id) {
return Uni.createFrom().item(() -> store.snapshot(id))
.runSubscriptionOn(Infrastructure.getDefaultWorkerPool());
}
}The JDBC read is blocking, so runSubscriptionOn moves it away from the event-loop thread. The first terminal snapshot is emitted, one following tick closes the stream, and duplicate entity versions never reach the client.
Polling PostgreSQL twice per second is a conscious baseline, not a universal prescription. It is easy to reason about, survives reconnects, and is adequate for modest enterprise job volume. At larger scale, publish job IDs through PostgreSQL notifications, Kafka, Redis, or another broker and let the SSE layer re-read the authoritative row before emitting. The database remains the source of truth; the broker becomes a wake-up signal.
Use the browser’s upload progress
The previous article chunked the file in JavaScript and counted each accepted chunk on the server. Chunking is justified when you need resumable transfer, retry of individual parts, or object-storage multipart uploads. It is extra protocol surface when you only need to show one normal upload.
The Fetch API still does not provide the same broadly available upload progress events. XMLHttpRequest.upload does. The browser fires progress events with loaded, total, and lengthComputable, which is exactly what the transfer bar needs.
The relevant browser code is small:
function upload(file) {
const body = new FormData();
body.append('file', file);
const request = new XMLHttpRequest();
request.open('POST', '/api/imports');
request.upload.addEventListener('progress', event => {
if (!event.lengthComputable) {
transferProgress.removeAttribute('value');
transferPercent.textContent = 'sending';
return;
}
const percent = Math.round(event.loaded * 100 / event.total);
transferProgress.value = percent;
transferPercent.textContent = `${percent}%`;
});
request.addEventListener('load', () => {
if (request.status !== 202) {
showUploadError(request.responseText);
return;
}
const job = JSON.parse(request.responseText);
localStorage.setItem('honest-progress-job', job.id);
renderJob(job);
follow(job.id);
});
request.send(body);
}After the 202, EventSource owns the second display:
function follow(id) {
const source = new EventSource(`/api/imports/${id}/events`);
source.addEventListener('progress', event => {
const job = JSON.parse(event.data);
renderJob(job);
if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(job.state)) {
source.close();
localStorage.removeItem('honest-progress-job');
}
});
}On page load, the full application reads the stored job ID, calls GET /api/imports/{id}, renders that snapshot, and reconnects only if the job is non-terminal. localStorage is a convenience pointer, not trusted job state.
The UI uses native <progress> elements and an aria-live status message. When percent is null, JavaScript removes the element’s value attribute and the browser exposes an indeterminate state. A screen-reader user gets the same phase and message as a sighted user rather than a silent animated stripe.
Run it and break it
Start dev mode:
./mvnw quarkus:devOpen http://localhost:8080 and click Run demo file. You should see the transfer reach 100% first. The job then moves through QUEUED, VALIDATING, IMPORTING, FINALIZING, and SUCCEEDED.
Now run three more checks:
Start another import and reload the page while rows are being imported. The job ID survives locally, the snapshot comes back from PostgreSQL, and SSE resumes.
Start an import and press Cancel job. A queued job cancels immediately; a running job stops after its current batch.
Upload a CSV containing the same invoice number twice. Validation fails before any published row exists, and the job preserves the error for a later
GET.
You can create a job without the UI:
curl -i \
-F file=@src/main/resources/META-INF/resources/sample-invoices.csv \
http://localhost:8080/api/importsThe response starts like this:
HTTP/1.1 202 Accepted
Location: http://localhost:8080/api/imports/4bd55d0a-...
Content-Type: application/jsonRun the tests:
./mvnw testThe seven tests cover successful publication, the terminal SSE event, queued cancellation, duplicate detection with no published partial rows, rejected file types, and focused CSV rules. The scheduler is disabled in the test profile so each integration test calls the worker deterministically.
Where the basic scheduler stops scaling
This sample has one more boundary: it is a single-instance design.
ConcurrentExecution.SKIP prevents overlap only inside one application instance. If you deploy three replicas, each replica has its own trigger. PostgreSQL locking can protect a job claim, but it does not make local staged files visible to another pod. Startup recovery on one replica must not reset work that another healthy replica owns.
For multiple replicas, change three elements:
Put uploads in shared object storage instead of a local staging directory.
Claim work with an atomic database operation such as
FOR UPDATE SKIP LOCKED, plus a lease and heartbeat so abandoned work can be recovered safely.Either let every replica compete for jobs safely or use the Quarkus Quartz extension with a clustered store when you need coordinated triggers, calendars, and persistent scheduling.
The browser and API contract do not need to change. POST, GET, DELETE, and SSE still address the same durable job resource.
What WebSockets Next would change
I did not add quarkus-websockets-next to this application. Progress is server-to-client, while create and cancel are ordinary HTTP commands. SSE matches that asymmetry and gives us a smaller protocol.
WebSockets Next becomes interesting when the control plane is genuinely bidirectional. Imagine that the user can pause, resume, change priority, answer a validation question, or subscribe to several jobs over one connection. A WebSocket endpoint could receive those commands and push state changes on the same channel.
That changes the edge architecture, not the job architecture:
The browser opens a WebSocket and sends an explicit
subscribemessage for a job ID.The server authenticates that subscription and maps connections to authorized jobs.
Cancel, pause, and resume become socket messages instead of HTTP methods.
Reconnect needs an application protocol: the client must resubscribe and send the last version it saw.
Multiple application replicas need shared fan-out through a broker or a routed connection strategy.
PostgreSQL still leads the job execution. The scheduler still processes it. A socket must not become the only place that knows progress or cancellation state, because sockets disconnect and application instances restart.
Use WebSockets Next when bidirectional interaction is part of managing lifecycle complexity. Do not add it just to animate a percentage bar.
The progress bar is part of the domain
The old version asked, “How many bytes reached Quarkus?” This version asks a more valuable question: “What can the application truthfully say about the work right now?”
Sometimes the answer is 42 of 200,000 invoices. Sometimes it is “validating,” with no percentage. Sometimes it is “cancellation requested; finishing the current batch.” Those are not UI details. They are business state, and they deserve the same durability and failure semantics as the import itself.
Keep the two clocks separate. Return a job resource when the upload ends. Persist every meaningful transition. Treat SSE or WebSockets as delivery mechanisms, not memory. Then 100% can mean what users think it means: the work is actually done.




