sch39

Implementing Resumable File Upload with Tus in Spring Boot Using tus-java-server

Oct 2026 · 15 min read

A guide to implementing Tus protocol-based resumable file upload in Spring Boot using the tus-java-server library, including service configuration, servlet endpoints, storage, cleanup, metadata, and tus-js-client integration.

Large file uploads are prone to failure when the connection drops. If an application only uses a regular upload endpoint, the user has to resend the file from scratch. The Tus protocol solves this problem by storing the upload offset on the server so the client can resume sending from the last byte successfully received.

In the previous article, the POST, HEAD, and PATCH endpoints were written manually. That approach is useful for learning the basics of the protocol, but it requires a lot of extra work to handle header validation, offset conflicts, expiration, termination, checksums, locking, and spec-compliant error responses.

This article follows the actual implementation of the tus_library project, which uses Spring Boot 4.1.1, Java 21, tus-java-server 1.0.0-3.3, Thymeleaf, and tus-js-client major version 4. That Java library was also known in older versions as tus-server-lib and provides TusFileUploadService to process Tus requests through the Jakarta Servlet API.

Basic Concepts of the Tus Protocol

Tus is an HTTP-based resumable upload protocol. The server creates an upload resource, stores the position of bytes already received, and then ensures each subsequent chunk is written at the correct offset.

The basic flow:

  • OPTIONS: the client reads the version, extensions, and size limits supported by the server.

  • Creation: the client sends a POST with Upload-Length and optional metadata. The server returns 201 Created plus a Location header.

  • Head: the client sends a HEAD to the resource URL to read the latest Upload-Offset.

  • PATCH: the client sends a chunk with Content-Type: application/offset+octet-stream and Upload-Offset.

  • Termination: the client can send a DELETE to cancel and remove the resource if the termination extension is available.

  • Expiration: abandoned uploads can be given a lifetime and cleaned up periodically.

Tus requests use headers like the following:

text
Tus-Resumable: 1.0.0
Upload-Length: 10485760
Upload-Offset: 5242880
Upload-Metadata: filename ZG9rdW1lbi5wZGY=,filetype YXBwbGljYXRpb24vcGRm

Unlike a manual implementation, metadata parsing, protocol validation, locking, and offset management are delegated to the library.

Dependency Choices

The project uses the Spring Boot parent 4.1.1, Java 21, and the following dependencies:

xml
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.1</version>
    <relativePath/>
</parent>

<properties>
    <java.version>21</java.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>

    <dependency>
        <groupId>me.desair.tus</groupId>
        <artifactId>tus-java-server</artifactId>
        <version>1.0.0-3.3</version>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

The tus-java-server 1.0.0-3.x series requires Java 17 or newer and uses the jakarta.servlet package. Therefore, the controller imports jakarta.servlet.http.HttpServletRequest and jakarta.servlet.http.HttpServletResponse, not the old javax.servlet package.

Spring Boot Project Setup

The source structure of the project used:

text
src/main/java/dev/sch39/tus_library/
├── config/
│   └── TusConfig.java
├── controller/
│   ├── TusUploadController.java
│   └── UploadPageController.java
├── maintenance/
│   └── TusUploadCleanup.java
└── TusLibraryApplication.java

src/main/resources/
├── static/js/
│   └── upload.js
├── templates/
│   └── upload.html
└── application.properties

src/test/java/dev/sch39/tus_library/
└── TusLibraryApplicationTests.java

The main class enables scheduling so the application can run cleanup periodically:

java
package dev.sch39.tus_library;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@EnableScheduling
@SpringBootApplication
public class TusLibraryApplication {

    public static void main(String[] args) {
        SpringApplication.run(TusLibraryApplication.class, args);
    }
}

Tus Endpoint Configuration

The actual configuration stores uploads in a local directory, exposes the /files endpoint, and runs cleanup every five minutes:

properties
spring.application.name=tus-library

tus.upload-uri=/files
tus.storage-path=./uploads
tus.max-upload-size=1073741824
tus.expiration-period=86400000
tus.cleanup-interval=300000

Those values mean:

  • tus.upload-uri=/files — the base URI accepted by the client.

  • tus.storage-path=./uploads — the library's internal storage root, relative to the JVM working directory.

  • tus.max-upload-size=1073741824 — maximum upload size of 1 GiB.

  • tus.expiration-period=86400000 — upload lifetime of 24 hours in milliseconds.

  • tus.cleanup-interval=300000 — fixed delay for cleanup of 300,000 ms or five minutes.

withUploadUri(...) must match the path visible to the application. If the application runs behind a reverse proxy or uses a context path, routing and forwarded headers must be configured consistently.

The path ./uploads does not mean every file is directly in that folder. With the built-in filesystem backend, the visible storage layout is:

text
uploads/
├── locks/
└── uploads/
    └── <upload-id>/
        ├── data
        └── info

The uploads/uploads folder is not a typo. The first folder is the root from the configuration, while the second folder is the library's internal structure. The data file contains the upload bytes and info contains the UploadInfo object in Java serialization format, not JSON. This internal structure should not be treated as the application's storage API.

Service Implementation

TusFileUploadService is created once as a Spring bean:

java
package dev.sch39.tus_library.config;

import me.desair.tus.server.TusFileUploadService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class TusConfig {

    @Bean
    public TusFileUploadService tusFileUploadService(
            @Value("${tus.upload-uri}") String uploadUri,
            @Value("${tus.storage-path}") String storagePath,
            @Value("${tus.max-upload-size}") Long maxUploadSize,
            @Value("${tus.expiration-period}") Long expirationPeriod) {

        return new TusFileUploadService()
                .withUploadUri(uploadUri)
                .withStoragePath(storagePath)
                .withMaxUploadSize(maxUploadSize)
                .withUploadExpirationPeriod(expirationPeriod)
                .withThreadLocalCache(true)
                .withUploadDeduplication(true);
    }
}

Purpose: Configures the Tus service that handles resource creation, offset checks, chunk writing, metadata, expiration, deduplication, and upload locking.

The configuration that is actually active in the project:

  • withStoragePath(...) uses the local filesystem as the storage backend.

  • withUploadUri(...) determines the main Tus endpoint.

  • withMaxUploadSize(...) limits the length of files that can be created.

  • withUploadExpirationPeriod(...) determines the upload lifetime.

  • withThreadLocalCache(true) enables per-thread caching during request processing.

  • withUploadDeduplication(true) enables the library's built-in deduplication.

The project does not enable .withDownloadFeature(), custom storage, custom locking, owner key, completion callback, or automatic movement to permanent storage. This means completed uploads remain in the storage managed by the library.

For multi-replica applications, a relative local filesystem like this example is not enough. Storage and locking need to be designed to be consistent across replicas, for example through a shared volume or a service implementation suited to the deployment.

Controller Implementation

The controller does not implement POST, HEAD, or PATCH manually. Every mapped method is forwarded to TusFileUploadService.process(...):

java
package dev.sch39.tus_library.controller;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import me.desair.tus.server.TusFileUploadService;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

import java.io.IOException;

@RestController
public class TusUploadController {

    private final TusFileUploadService tusFileUploadService;

    public TusUploadController(TusFileUploadService tusFileUploadService) {
        this.tusFileUploadService = tusFileUploadService;
    }

    @RequestMapping(value = { "/files", "/files/**" }, method = {
            RequestMethod.POST,
            RequestMethod.PUT,
            RequestMethod.PATCH,
            RequestMethod.HEAD,
            RequestMethod.DELETE,
            RequestMethod.OPTIONS,
            RequestMethod.GET
    })
    public void processUpload(
            HttpServletRequest request,
            HttpServletResponse response) throws IOException {

        tusFileUploadService.process(request, response);
    }
}

Purpose: Bridges Spring MVC requests with the protocol implementation provided by the library.

The mapping must include the base path and subpaths:

text
/files
/files/**

The base path is used for creation and discovery, while subpaths are used by the unique upload resources returned through the Location header. The OPTIONS method must reach the controller so the client can read the server's capabilities.

Although GET is mapped by the controller, download is not yet available because the service configuration does not call .withDownloadFeature(). PUT is also forwarded to the library, but the frontend in this project does not call it directly.

Upload Page Implementation

The UI page is available at exactly GET /upload; the project has no mapping or redirect for /:

java
package dev.sch39.tus_library.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class UploadPageController {

    @GetMapping("/upload")
    public String uploadPage() {
        return "upload";
    }
}

The view name upload is rendered from src/main/resources/templates/upload.html.

File Metadata Handling

The frontend sends filename and filetype metadata. The library parses the Upload-Metadata header and provides it through UploadInfo.

text
Upload-Metadata: filename ZG9rdW1lbi5wZGY=,filetype YXBwbGljYXRpb24vcGRm

The project currently does not read metadata or move completed uploads. The following example is an optional next step, not part of the running implementation:

java
import me.desair.tus.server.upload.UploadInfo;

UploadInfo uploadInfo = tusFileUploadService.getUploadInfo(uploadUri);

if (uploadInfo != null && uploadInfo.hasMetadata()) {
    String filename = uploadInfo.getMetadata().get("filename");
    String filetype = uploadInfo.getMetadata().get("filetype");
}

Upload status can be checked through offset, length, and isUploadInProgress(). Complete data can be read through getUploadedBytes(uploadUri):

java
try (var inputStream = tusFileUploadService.getUploadedBytes(uploadUri)) {
    // Copy to object storage or the application's domain storage.
}

Metadata comes from the client and must not be used directly as a path. Take the last file name and validate it according to the application's needs:

java
String safeFilename = java.nio.file.Paths.get(filename)
        .getFileName()
        .toString();

After the stream is closed and the file is successfully moved to permanent storage, the temporary resource can be deleted:

java
tusFileUploadService.deleteUpload(uploadUri);

Cleanup of Expired Uploads

Expiration does not clean up all resources without a maintenance call. The project runs cleanup() through the following scheduler:

java
package dev.sch39.tus_library.maintenance;

import me.desair.tus.server.TusFileUploadService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class TusUploadCleanup {

    private static final Logger log =
            LoggerFactory.getLogger(TusUploadCleanup.class);

    private final TusFileUploadService tusFileUploadService;

    public TusUploadCleanup(TusFileUploadService tusFileUploadService) {
        this.tusFileUploadService = tusFileUploadService;
    }

    @Scheduled(fixedDelayString = "${tus.cleanup-interval:3600000}")
    public void cleanupExpiredUploads() {
        try {
            tusFileUploadService.cleanup();
        } catch (IOException exception) {
            log.error(
                "Failed to clean up expired Tus uploads",
                exception
            );
        }
    }
}

A number without a unit in fixedDelayString is read in milliseconds. The annotation fallback is 3600000 or one hour, but application.properties overrides it with 300000, so the actual implementation waits five minutes after one execution finishes before running the next cleanup.

Do not use an interval that is too aggressive. For example, a value of 120 means 120 ms, not 120 seconds. Cleanup that runs very frequently can overlap with info metadata serialization and produce warnings about reading a temporary file that is currently empty or being rewritten.

Integration with the Frontend (tus-js-client)

The actual template is a responsive upload card with a file summary, native <progress>, byte count, percentage, toned status, focus state, mobile layout, and prefers-reduced-motion support. The most important integration parts are:

html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="color-scheme" content="light">
    <title>Resumable File Upload</title>
</head>
<body>
    <main class="page-shell">
        <section
            class="upload-card"
            id="uploadApp"
            th:data-upload-endpoint="@{/files}"
            data-upload-endpoint="/files">

            <div class="file-field">
                <label class="field-label" for="fileInput">Choose file</label>
                <input type="file" id="fileInput">
                <div class="file-summary" aria-live="polite">
                    <span id="fileName">No file selected</span>
                    <span id="fileSize">—</span>
                </div>
            </div>

            <div class="progress-panel">
                <span id="percentage">0%</span>
                <progress id="progress" value="0" max="100"
                    aria-label="File upload progress">0%</progress>
                <span id="transferred">0 B of 0 B</span>
                <span>5 MiB chunk</span>
            </div>

            <p id="status" role="status" aria-live="polite"
                aria-atomic="true" data-tone="neutral">
                Choose a file to start the upload.
            </p>

            <div class="actions">
                <button id="uploadButton" type="button" disabled>
                    Start upload
                </button>
                <button id="pauseButton" type="button" disabled>
                    Pause
                </button>
                <button id="resumeButton" type="button" disabled>
                    Resume
                </button>
            </div>
        </section>
    </main>

    <script defer
        src="https://cdn.jsdelivr.net/npm/tus-js-client@4/dist/tus.min.js">
    </script>
    <script defer src="/js/upload.js" th:src="@{/js/upload.js}"></script>
</body>
</html>

The full CSS remains inline in the actual template. Two important Thymeleaf expressions are:

  • th:data-upload-endpoint="@{/files}" produces an endpoint that takes the context path into account.

  • th:src="@{/js/upload.js}" produces a static resource URL that also takes the context path into account.

The JavaScript is in src/main/resources/static/js/upload.js, so Spring Boot serves it through /js/upload.js. The folder src/main/resources/js does not automatically become a static resource.

UI State

The frontend uses explicit state:

javascript
const activeStates = new Set([
    'checking-resume',
    'uploading',
    'pausing',
    'paused'
]);

let upload = null;
let uploadAttempt = 0;
let state = fileInput.files.length > 0
    ? 'file-selected'
    : 'idle';

The possible states are idle, file-selected, checking-resume, uploading, pausing, paused, completed, and failed. The renderState() function is the single place to manage buttons, file input, status, and ARIA role:

javascript
function renderState() {
    const file = selectedFile();
    const isActive = activeStates.has(state);

    uploadButton.disabled = !file || isActive || state === 'pausing';
    uploadButton.textContent = state === 'failed'
        ? 'Try again'
        : 'Start upload';
    pauseButton.disabled = state !== 'uploading';
    resumeButton.disabled = state !== 'paused';
    fileInput.disabled = isActive || state === 'pausing';

    status.textContent = statusMessage;
    status.dataset.tone = statusTone;
    status.setAttribute(
        'role',
        statusTone === 'error' ? 'alert' : 'status'
    );

    updateFileSummary();
}

The file input and buttons are disabled in the appropriate states so the user cannot start multiple uploads or change the file while an operation is active.

Tus Configuration

The endpoint is read from the template's data attribute, not hard-coded in JavaScript:

javascript
const uploadApp = document.getElementById('uploadApp');
const endpoint = uploadApp?.dataset.uploadEndpoint;

Each attempt creates a local instance and an incrementing token. Old callbacks should only update the UI if the instance and token are still active:

javascript
function isCurrentUpload(candidate, attempt) {
    return upload === candidate && uploadAttempt === attempt;
}

const attempt = ++uploadAttempt;
const currentUpload = new window.tus.Upload(file, {
    endpoint,
    retryDelays: [0, 1000, 3000, 5000],
    chunkSize: 5 * 1024 * 1024,
    removeFingerprintOnSuccess: true,
    metadata: {
        filename: file.name,
        filetype: file.type || 'application/octet-stream'
    },
    onError(error) {
        if (!isCurrentUpload(currentUpload, attempt)) {
            return;
        }

        failUpload(
            'Upload failed. Check your connection then press “Try again”.',
            error
        );
    },
    onProgress(bytesUploaded, bytesTotal) {
        if (!isCurrentUpload(currentUpload, attempt)) {
            return;
        }

        state = 'uploading';
        setStatus(
            'Upload is in progress. You can pause it at any time.',
            'info'
        );
        updateProgress(bytesUploaded, bytesTotal);
        renderState();
    },
    onSuccess() {
        if (!isCurrentUpload(currentUpload, attempt)) {
            return;
        }

        state = 'completed';
        updateProgress(file.size, file.size);
        setStatus(
            `Upload complete. Resource stored at ${currentUpload.url}`,
            'success'
        );
        renderState();
    }
});

This configuration provides the following behavior:

  • Chunk size of 5 MiB.

  • Retries happen immediately, then after 1, 3, and 5 seconds.

  • Empty MIME type uses the application/octet-stream fallback.

  • The upload fingerprint is removed after success through removeFingerprintOnSuccess: true.

  • Technical errors are written to the console, while the user gets a concise recovery message.

  • The instance guard prevents old upload callbacks from changing the UI of a newer upload.

removeFingerprintOnSuccess makes the same file uploadable again as a new resource after the previous upload is complete, rather than being treated as a resume of an already complete resource.

Resuming Previous Uploads

Before calling start(), the frontend reads history from the browser:

javascript
try {
    const previousUploads =
        await currentUpload.findPreviousUploads();

    if (!isCurrentUpload(currentUpload, attempt)) {
        return;
    }

    if (previousUploads.length > 0) {
        currentUpload.resumeFromPreviousUpload(previousUploads[0]);
        setStatus(
            'Previous upload found. Resuming from the last progress…',
            'info'
        );
    } else {
        setStatus('Starting a new upload…', 'info');
    }

    state = 'uploading';
    renderState();
    currentUpload.start();
} catch (error) {
    if (isCurrentUpload(currentUpload, attempt)) {
        failUpload(
            'Upload history cannot be read. Press “Try again” to retry.',
            error
        );
    }
}

findPreviousUploads() looks for fingerprints and resource URLs stored by tus-js-client in browser storage. The implementation picks the first record if there are multiple results. When resuming, the client uses the old resource and requests the latest offset from the server before continuing with chunks.

The browser does not store the File object again. After a reload, the user still has to select the same local file for the fingerprint to match. The history also depends on the origin, browser profile, and browser storage availability.

Pause and Resume on the Same Page

Pause uses abort() without termination:

javascript
state = 'pausing';
setStatus('Pausing the running request…', 'warning');
renderState();

try {
    await currentUpload.abort();

    if (!isCurrentUpload(currentUpload, attempt)) {
        return;
    }

    state = 'paused';
    setStatus(
        'Upload paused. Press “Resume” to continue from the last offset.',
        'warning'
    );
    renderState();
} catch (error) {
    if (!isCurrentUpload(currentUpload, attempt)) {
        return;
    }

    state = 'uploading';
    setStatus(
        'Upload could not be paused and is still running.',
        'error'
    );
    console.error('Failed to pause Tus upload:', error);
    renderState();
}

abort() stops the active client request, but does not send DELETE and does not remove the server resource. The Resume button calls start() on the same instance:

javascript
if (upload && state === 'paused') {
    state = 'uploading';
    setStatus(
        'Resuming upload from the last offset…',
        'info'
    );
    renderState();
    upload.start();
}

The partial resource remains on the server so it can be resumed until it is explicitly deleted or becomes eligible for cleanup.

Testing

Run the UI through:

text
http://localhost:8080/upload

Check endpoint capabilities with OPTIONS:

bash
curl -X OPTIONS http://localhost:8080/files -i

The server returns 204 No Content along with headers such as Tus-Version, Tus-Extension, Tus-Checksum-Algorithm, and Tus-Max-Size according to the library's capabilities and the configuration.

Create a new upload resource:

bash
curl -X POST http://localhost:8080/files \
  -H "Tus-Resumable: 1.0.0" \
  -H "Upload-Length: 11" \
  -H "Upload-Metadata: filename dGVzdC50eHQ=" \
  -i

The creation response verified in this implementation uses 201 Created and a relative Location:

text
HTTP/1.1 201 Created
Location: /files/<upload-id>
Tus-Resumable: 1.0.0

Read the initial offset using that URL:

bash
curl -I http://localhost:8080/files/<upload-id> \
  -H "Tus-Resumable: 1.0.0"

The tested implementation returns status 204 No Content for HEAD, with an initial offset of 0:

text
HTTP/1.1 204 No Content
Upload-Length: 11
Upload-Offset: 0
Tus-Resumable: 1.0.0

Send a chunk:

bash
curl -X PATCH http://localhost:8080/files/<upload-id> \
  -H "Tus-Resumable: 1.0.0" \
  -H "Upload-Offset: 0" \
  -H "Content-Type: application/offset+octet-stream" \
  --data-binary "hello world" \
  -i

A successful response uses 204 No Content and provides the new offset:

text
HTTP/1.1 204 No Content
Tus-Resumable: 1.0.0
Upload-Offset: 11

The only automated test currently present is a Spring context smoke test:

java
@SpringBootTest
class TusLibraryApplicationTests {

    @Test
    void contextLoads() {
    }
}

That test proves the context can be created, but does not yet test the protocol or UI. The last result ran one test without failures or errors. The following scenarios are still recommended and have not been implemented as automated tests:

  • OPTIONS returns the server's capabilities.

  • POST produces 201 and Location.

  • HEAD returns an initial offset of 0.

  • PATCH increases the offset by the number of bytes.

  • An incorrect offset is rejected with a conflict.

  • Uploads above tus.max-upload-size are rejected.

  • DELETE removes the resource when termination is available.

  • Uploads can be resumed after a restart with persistent storage.

  • Cleanup removes expired uploads.

  • GET /upload and /js/upload.js are accessible.

  • The frontend start, pause, resume, retry, reload, and completed flows work in the browser.

Why It Works

  1. Spring MVC forwards all requests on /files and /files/** to a single TusFileUploadService instance.

  2. The library reads and validates Tus headers based on the HTTP method received.

  3. Metadata, file length, offset, and upload data are stored through the library's filesystem backend.

  4. A HEAD request returns the last stored offset.

  5. A PATCH request is only accepted if the client offset matches the server offset.

  6. Locking prevents multiple requests from writing to the same resource unsafely.

  7. tus-js-client stores fingerprints and resource URLs for retry or resume without resending bytes already received.

  8. UI state and the attempt guard prevent duplicate uploads and old callbacks from changing the display.

  9. Expiration and scheduled cleanup control abandoned upload resources.

With the library, the application does not need to write an Upload-Metadata parser, manage RandomAccessFile, or compose the entire protocol response manually. The application is still responsible for authentication, authorization, business validation, moving completed files, and the permanent storage lifecycle.

Edge Cases

  • URI mismatch: the withUploadUri(...) value must match the controller endpoint and the path seen by the application.

  • Working directory: ./uploads is relative to the JVM working directory, which can differ when the application runs as a service or container.

  • Reverse proxy: make sure forwarded headers, scheme, host, and prefix are consistent so the Location can be used by the client.

  • Spring Security and CORS: the endpoint does not yet have security configuration; allow the necessary OPTIONS and apply authentication consistently.

  • Upload ownership: the controller currently calls process(request, response) without an ownerKey, so there is no resource isolation between users.

  • Deduplication: deduplication is active while ownership has not been applied. Evaluate privacy implications before using this pattern in a multi-tenant application.

  • Dangerous file names: metadata comes from the client; sanitize the filename and do not use it directly as a filesystem path.

  • File size: the server limits to 1 GiB, but the frontend does not precheck and only shows a generic error if the server rejects the upload.

  • Completed files: success status only means the upload is complete in the library storage. The project has not moved it to permanent storage or removed the temporary resource.

  • Abandoned uploads: pause is not delete. The partial resource remains available until it is resumed, deleted, or cleaned up.

  • Cleanup: an interval value without a unit is milliseconds. An interval that is too small can increase the chance of cleanup overlapping with metadata writing.

  • Multiple replicas: local storage and default locking are not enough for a distributed deployment without appropriate shared storage.

  • Download: GET is mapped, but .withDownloadFeature() is not active so the Tus URL is not the application's download endpoint.

  • Browser resume: history depends on the origin and browser storage; after a reload the user must select the same file.

  • Multiple histories: the implementation directly picks previousUploads[0] and does not offer a picker to the user.

  • CDN: the tus-js-client@4 bundle requires an internet connection, only locks the major version, and does not yet have SRI or a local fallback.

  • Resource URL: the UI displays the Tus URL after success; consider whether that URL should be exposed after access control is applied.

  • Observability: the project does not yet have metrics, tracing, audit trails, or completion callbacks.

  • Test coverage: the new automated test only verifies the Spring context, not protocol and frontend correctness.

Conclusion

tus-java-server reduces the complexity of implementing resumable uploads in Spring Boot. The controller only forwards servlet requests to TusFileUploadService, while the library handles creation, offset, chunks, metadata, expiration, termination, checksums, and locking according to the active capabilities and configuration.

The actual implementation also strengthens the browser side with a context-path-safe endpoint, 5 MiB chunks, retries, previous upload lookup, pause/resume, explicit UI state, asynchronous error handling, and guards against stale callbacks.

Even so, the library is not a replacement for the entire upload design of an application. Authentication, ownership, quotas, metadata and content validation, processing of completed files, observability, backup, permanent storage, and protocol tests still need to be designed according to system requirements.

References