Quarkus TUS

SSE Upload Progress

The extension provides optional Server-Sent Events (SSE) endpoints for streaming real-time upload progress to clients. This is useful for building progress bars and upload status indicators in web applications.

SSE is a convenience, not the integration contract. The extension’s contract is its CDI lifecycle events, which fire whether or not SSE is enabled; the bundled SSE endpoints are one consumer of them. An application that wants a different transport — its own SSE, a WebSocket, a webhook, a message queue — observes the CDI events directly and needs nothing from this page.

Enabling SSE

SSE is enabled by default. To disable it:

quarkus.tus.sse-enabled=false

When disabled, the SSE beans and endpoints are not registered at all (build-time decision).

Access Control

Both streams require the upload to exist, and — when quarkus.tus.auth-enabled is true — to belong to the caller. An unknown, malformed or unowned upload ID is answered with 404, indistinguishable from a missing upload so the endpoint cannot be used to probe for other users' upload IDs.

Only one subscriber is tracked per upload ID. Opening a second stream for the same upload closes the first, so a client that reconnects replaces its own stream rather than leaking the previous connection.

SSE Endpoints

Event Stream

GET /tus/events/{uploadId}

Opens a persistent SSE connection for the given upload, carrying its lifecycle:

  • connected — sent immediately on subscribe

  • progress — one per chunk written, with bytesUploaded, totalBytes and chunkSize

  • complete — the upload finished; a final progress at 100% precedes it

  • terminated — the upload was deleted

The server closes the stream after complete or terminated, which also releases the subscription — unless the upload was held open (see Held-Open Streams). For a progress bar alone, prefer the progress stream below.

Client-side JavaScript
const uploadId = "550e8400-e29b-41d4-a716-446655440000";
const eventSource = new EventSource(`/tus/events/${uploadId}`);

eventSource.addEventListener("connected", (e) => {
    console.log("Connected to upload stream");
});

eventSource.addEventListener("progress", (e) => {
    const data = JSON.parse(e.data);
    const percent = (data.bytesUploaded / data.totalBytes * 100).toFixed(1);
    console.log(`Progress: ${percent}%`);
});

eventSource.addEventListener("complete", (e) => {
    console.log("Upload complete!");
    eventSource.close();
});

eventSource.addEventListener("terminated", (e) => {
    console.log("Upload was deleted");
    eventSource.close();
});

Progress Stream

GET /tus/progress/{uploadId}

Opens an SSE connection that streams upload-progress events as chunks are written. Unlike the event stream above, this endpoint is focused specifically on progress data and delivers structured JSON payloads suitable for progress bars.

curl http://localhost:8080/tus/progress/550e8400-e29b-41d4-a716-446655440000

Each upload-progress event carries the bytes stored so far, the declared size and the integer percentage:

Event payload
{"percentage": 50, "uploadedBytes": 524288, "totalBytes": 1048576}

A subscriber that connects mid-upload receives the current state at once. The stream closes when the upload completes (after the 100% event) or is discarded.

SSE Event Types

Event Description

connected

Sent immediately when the SSE connection is established

progress

Sent after each chunk is written; includes bytes uploaded, total bytes, and percentage

complete

Sent when the upload reaches 100%; the SSE connection closes after this event

error

Sent if an error occurs during the upload

Custom Events

The TusSseService bean exposes a method for sending custom events to connected clients:

@Inject
TusSseService sseService;

void notifyScanComplete(String uploadId) {
    sseService.sendUploadEvent(uploadId, "scan-complete",
            "{\"clean\": true, \"scanner\": \"clamav\"}");
}

Clients can listen for custom events by name:

eventSource.addEventListener("scan-complete", (e) => {
    const result = JSON.parse(e.data);
    console.log(`Scan result: ${result.clean ? "clean" : "infected"}`);
});

Held-Open Streams

The upload finishing is not always the story finishing. A server that keeps working after the last byte — moving the file to its real destination, scanning it, transcoding it — may want to report that work on the same stream, but by default the server closes the stream the moment the upload completes, so events sent afterwards are lost.

Holding the stream open changes that. Call holdOpen any time before the upload completes — typically from a TusUploadCreatedEvent observer — and completion starts a timeout instead of closing the stream. Send whatever custom events the pipeline produces, then call finish when it is done:

@Inject
TusSseService sseService;

void onCreated(@Observes TusUploadCreatedEvent event) {
    sseService.holdOpen(event.uploadId());
}

void onCompleted(@Observes TusUploadCompletedEvent event) {
    sseService.sendUploadEvent(event.uploadId(), "relocating",
            "{\"target\": \"cold-storage\"}");
    archiveService.move(event.uploadId())
            .subscribe().with(done -> {
                sseService.sendUploadEvent(event.uploadId(), "archived", "{}");
                sseService.finish(event.uploadId());
            });
}

If finish never comes, the server closes the stream anyway after quarkus.tus.sse-hold-open-timeout-seconds (default 300), counted from the completion event, so an abandoned pipeline cannot leak the connection. finish is idempotent and harmless on an upload that was never held open. A client should therefore treat the stream’s closure — not the complete event — as the end of the story:

eventSource.addEventListener("archived", (e) => {
    console.log("File archived");
    eventSource.close();
});

Termination is unaffected: a deleted upload always closes its stream.