quarkus.tus.sse-enabled=false
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:
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, withbytesUploaded,totalBytesandchunkSize -
complete— the upload finished; a finalprogressat 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.
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:
{"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 |
|---|---|
|
Sent immediately when the SSE connection is established |
|
Sent after each chunk is written; includes bytes uploaded, total bytes, and percentage |
|
Sent when the upload reaches 100%; the SSE connection closes after this event |
|
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.