sch39
Part of 2 — Java Basics

Resumable File Upload in Java with the TUS Protocol: Complete Tutorial

Oct 2026 · 16 min read

A practical guide to implementing the TUS protocol in Java: dependency setup, Spring Boot server, tus-java-client client, chunk, offset, and metadata handling, through how to resume interrupted uploads along with debugging tips and best practices.

Uploading large files over plain HTTP is fragile. A POST request containing a 2 GB file can fail at the 90th second just because the WiFi connection flickers, and the user has to start over from scratch. The TUS protocol (resumable upload protocol) exists to solve that problem: an upload is split into small pieces (chunk), and each piece is sent together with offset information so the process can resume exactly from the last byte successfully stored.

This article discusses implementing TUS in Java practically: the protocol concepts, dependency setup, a TUS server in Spring Boot, the client on the Java side, handling chunk and metadata, through how to resume interrupted uploads along with debugging tips and best practices.

Why Is the TUS Protocol Needed?

Without a resumable protocol, the server just receives one giant request and stores it in memory or a temporary file. If the connection drops, everything is lost. TUS solves this with a few simple principles:

  • The upload resource has its own URL. After creating an upload, the server returns a Location that can be used multiple times.

  • The server always knows the last byte position. The Upload-Offset header is the single source of truth.

  • The operations are idempotent. Resending the same chunk or performing a HEAD does not corrupt data.

  • The protocol is over plain HTTP. It does not need WebSocket, does not need a persistent connection, and can go through a standard reverse proxy.

Because TUS is an open specification (tus.io), its ecosystem is broad: there are server implementations in Go (tusd), Node, Python, and Java, as well as clients in the browser (tus-js-client), Java, Python, and others. This matters if your frontend and backend later use different languages.

How the TUS Protocol Works

The core TUS protocol consists of only four HTTP operations:

  1. OPTIONS — asks for server capabilities (version, extensions, maximum size).

  2. POST — creates a new upload resource, returns Location.

  3. PATCH — sends the next data piece.

  4. HEAD — reads the current offset, used to resume an upload.

There is also DELETE to cancel an upload (the termination extension).

Important Headers

Header

Direction

Function

Tus-Resumable

Request & Response

Protocol version, currently 1.0.0. Required in every request; if absent, the server responds with 412 Precondition Failed.

Upload-Offset

Request & Response

Current byte position in the upload resource.

Upload-Length

Request

Total file size in bytes, sent when creating the upload.

Upload-Metadata

Request

Key-value pairs; the values are Base64-encoded.

Upload-Defer-Length

Request

Set to 1 if the total size is not known initially.

Tus-Version, Tus-Extension, Tus-Max-Size

Response

Server capabilities, sent during OPTIONS.

Upload-Expires

Response

When the upload expires and will be deleted (the expiration extension).

Note: TUS headers are case-insensitive, but the writing convention is as in the table above.

Request Flow

The following is the sequence of requests that occurs when uploading a 1 MB file in two pieces:

bash# 1. Server mengumumkan kapabilitasnya
OPTIONS /files
Tus-Resumable: 1.0.0
-> 204 No Content
   Tus-Version: 1.0.0
   Tus-Extension: creation,creation-with-upload,termination,expiration
   Tus-Max-Size: 1073741824

# 2. Buat resource upload
POST /files
Tus-Resumable: 1.0.0
Upload-Length: 1048576
Upload-Metadata: filename dmlkZW8ubXA0,filetype dmlkZW8vbXA0
-> 201 Created
   Location: http://localhost:8080/files/d0f1e2a3b4c5

# 3. Kirim 512 KB pertama
PATCH /files/d0f1e2a3b4c5
Tus-Resumable: 1.0.0
Upload-Offset: 0
Content-Type: application/offset+octet-stream
<512 KB binary>
-> 204 No Content
   Upload-Offset: 524288

# 4. Koneksi putus, lalu client bertanya lagi
HEAD /files/d0f1e2a3b4c5
Tus-Resumable: 1.0.0
-> 200 OK
   Upload-Offset: 524288
   Upload-Length: 1048576

# 5. Lanjutkan dari offset 524288
PATCH /files/d0f1e2a3b4c5
Tus-Resumable: 1.0.0
Upload-Offset: 524288
Content-Type: application/offset+octet-stream
<512 KB binary>
-> 204 No Content
   Upload-Offset: 1048576

Two rules most often causing bugs:

  • PATCH must use Content-Type: application/offset+octet-stream. If not, the server returns 415 Unsupported Media Type.

  • The Upload-Offset in the request must exactly match the offset stored on the server. If not, the response is 409 Conflict along with the correct Upload-Offset header.

TUS Library Options in Java

Instead of writing the protocol from scratch, there are two official libraries you can use:

Library

Side

Suited for

tus-java-server

Server

A server implementation based on the Servlet API. Can be installed in standalone Tomcat/Jetty, Spring Boot MVC, or a regular Servlet application.

tus-java-client

Client

Java desktop, CLI, worker, or service-to-service applications that need to upload files to a TUS endpoint.

tusd (Go)

Server

A very mature non-Java alternative. Suitable if you want a separate server and the Java backend only receives webhooks.

For Spring Boot applications, tus-java-server is the most practical choice: you simply wrap it in a servlet and register it as a bean. If you use WebFlux, tus-java-server cannot be used because it depends on the Servlet API — use tusd as a sidecar.

Maven coordinates and TUS Java library versions can change over time. Always verify the groupId, artifactId, and latest version on Maven Central or the official repository README before copying the block below.

Setting Up the Project and Dependencies

Create a Spring Boot project (Maven) and add the following dependencies:

xml<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.5</version>
  </parent>

  <properties>
    <java.version>17</java.version>
  </properties>

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

    <!-- Server TUS (Servlet API) -->
    <dependency>
      <groupId>io.tus.java</groupId>
      <artifactId>tus-java-server</artifactId>
      <version>0.5.6</version>
    </dependency>

    <!-- Client TUS, biasanya dipakai di modul/aplikasi terpisah -->
    <dependency>
      <groupId>io.tus.java.client</groupId>
      <artifactId>tus-java-client</artifactId>
      <version>0.4.3</version>
    </dependency>
  </dependencies>
</project>

Pay attention to Servlet namespace compatibility. Spring Boot 3.x uses jakarta.servlet, while many older tus-java-server releases still use javax.servlet. If the error ClassNotFoundException: javax.servlet.http.HttpServlet appears, the options are:

  • Use a tus-java-server version that already supports Jakarta EE 9+, or

  • Temporarily downgrade to Spring Boot 2.7 (which still uses javax.servlet), or

  • Run the TUS server as a separate application with Spring Boot 2.7 and place it behind the same reverse proxy.

Implementing a TUS Server in Spring Boot

First, create a servlet that delegates all HTTP methods to TusFileUploadService:

javapackage com.example.tus;

import io.tus.java.server.TusFileUploadService;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

public class TusUploadServlet extends HttpServlet {

    private final TusFileUploadService service;

    public TusUploadServlet(TusFileUploadService service) {
        this.service = service;
    }

    @Override
    protected void doOptions(HttpServletRequest req, HttpServletResponse resp) {
        service.process(req, resp);   // capability discovery
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp)
            throws ServletException, IOException {
        service.process(req, resp);   // POST /files  -> buat upload baru
    }

    @Override
    protected void doHead(HttpServletRequest req, HttpServletResponse resp) {
        service.process(req, resp);   // HEAD /files/{id} -> baca offset
    }

    @Override
    protected void doPatch(HttpServletRequest req, HttpServletResponse resp) {
        service.process(req, resp);   // PATCH /files/{id} -> kirim chunk
    }

    @Override
    protected void doDelete(HttpServletRequest req, HttpServletResponse resp) {
        service.process(req, resp);   // DELETE /files/{id} -> batalkan upload
    }
}

Servlet Registration and Configuration

Register the service and its servlet as Spring beans. This is where you configure the storage directory, the public URI, and a listener to monitor progress.

javapackage com.example.tus;

import io.tus.java.server.TusFileUploadService;
import io.tus.java.server.TusUploadInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class TusConfig {

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

    @Bean
    public TusFileUploadService tusFileUploadService() {
        return new TusFileUploadService()
                .withUploadUri("/files")
                .withStoragePath("/var/app/tus-uploads")
                .withThreadLocalCache(true)
                .withUploadInfoListener(new TusFileUploadService.UploadInfoListener() {
                    @Override
                    public void update(TusUploadInfo info) {
                        log.info("upload id={} offset={}/{} metadata={}",
                                info.getID(), info.getOffset(),
                                info.getLength(), info.getMetadata());
                    }
                });
    }

    @Bean
    public ServletRegistrationBean<TusUploadServlet> tusUploadServlet(
            TusFileUploadService service) {

        ServletRegistrationBean<TusUploadServlet> registration =
                new ServletRegistrationBean<>(new TusUploadServlet(service));
        registration.addUrlMappings("/files", "/files/*");
        registration.setMultipartEnabled(false);
        registration.setLoadOnStartup(1);
        return registration;
    }
}

Some important notes from the configuration above:

  • withUploadUri(\"/files\") must match the servlet URL mapping. This value is what the server uses to build the Location header.

  • withStoragePath(...) points to a directory on disk. For production, point it to a persistent volume, not /tmp.

  • setMultipartEnabled(false) prevents Spring from trying to parse the PATCH body as a multipart form.

  • The default implementation stores upload state as a companion file on disk. If you need multi-node, replace the state storage part with a shared database (see the best practices section).

How the Offset Is Validated

So as not to merely use the library as a black box, it is worth understanding the core logic that happens inside the PATCH handler. It looks roughly like this if written manually:

java@PatchMapping(path = "/files/{id}", consumes = "application/offset+octet-stream")
public void patch(@PathVariable String id,
                  @RequestHeader("Tus-Resumable") String tusVersion,
                  @RequestHeader("Upload-Offset") long clientOffset,
                  HttpServletRequest request,
                  HttpServletResponse response) throws IOException {

    if (!"1.0.0".equals(tusVersion)) {
        response.setStatus(HttpServletResponse.SC_PRECONDITION_FAILED);
        return;
    }

    long serverOffset = uploadStore.getOffset(id);

    // Offset tidak sinkron -> beri tahu client posisi yang benar
    if (serverOffset != clientOffset) {
        response.setStatus(HttpServletResponse.SC_CONFLICT);
        response.setHeader("Upload-Offset", String.valueOf(serverOffset));
        response.setHeader("Tus-Resumable", "1.0.0");
        return;
    }

    try (InputStream body = request.getInputStream()) {
        long written = uploadStore.append(id, body);   // append, bukan overwrite
        long newOffset = serverOffset + written;

        response.setStatus(HttpServletResponse.SC_NO_CONTENT);
        response.setHeader("Upload-Offset", String.valueOf(newOffset));
        response.setHeader("Tus-Resumable", "1.0.0");

        if (newOffset == uploadStore.getLength(id)) {
            uploadStore.markComplete(id);
        }
    }
}

The key is in the append method: data is added to the end of the file, not rewritten from the beginning. Therefore, resuming is enough by resending a PATCH with the last offset reported by HEAD.

To prevent two simultaneous PATCH operations from corrupting the file, wrap the read-offset-and-append operation in a per-upload-ID lock:

javaprivate final ConcurrentMap<String, ReentrantLock> locks = new ConcurrentHashMap<>();

private ReentrantLock lockFor(String uploadId) {
    return locks.computeIfAbsent(uploadId, k -> new ReentrantLock());
}

// di dalam handler PATCH
ReentrantLock lock = lockFor(id);
lock.lock();
try {
    // validasi offset + append data
} finally {
    lock.unlock();
}

Implementing a TUS Client in Java

On the uploader side, tus-java-client handles almost all protocol details: creating resources, sending PATCH, reading offsets, and retrying failed pieces.

javapackage com.example.tus.client;

import io.tus.java.client.ProtocolException;
import io.tus.java.client.TusClient;
import io.tus.java.client.TusUpload;
import io.tus.java.client.TusUploader;
import io.tus.java.client.TusURLMemoryStore;

import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;

public class UploadContoh {

    public static void main(String[] args) throws IOException, ProtocolException {
        File file = new File("/data/video.mp4");

        TusClient client = new TusClient();
        client.setUploadCreationURL(new URL("http://localhost:8080/files"));

        // Simpan pemetaan fingerprint -> URL upload agar bisa di-resume
        client.enableResuming(new TusURLMemoryStore());

        Map<String, String> metadata = new HashMap<>();
        metadata.put("filename", file.getName());
        metadata.put("filetype", "video/mp4");
        metadata.put("user_id", "12345");

        TusUpload upload = new TusUpload(file);
        upload.setMetadata(metadata);

        // Kalau fingerprint file ini pernah diunggah, client akan
        // memakai URL upload yang lama dan melanjutkan dari offset terakhir.
        TusUploader uploader = client.resumeOrCreateUpload(upload);
        uploader.setChunkSize(5 * 1024 * 1024);   // 5 MB per PATCH

        do {
            long uploaded = uploader.getOffset();
            double progress = (double) uploaded / upload.getSize() * 100;
            System.out.printf("Progres: %.2f%% (%d/%d byte)%n",
                    progress, uploaded, upload.getSize());
        } while (uploader.uploadChunk() > -1);

        // Tutup koneksi HTTP dan bersihkan sumber daya
        uploader.finish();

        System.out.println("Upload selesai: " + upload.getUploadURL());
    }
}

Automatic Resume on the Client Side

TusURLMemoryStore only stores the mapping in memory, so it is lost when the Java process dies. For resume that truly survives restarts, implement your own TusURLStore:

javapackage com.example.tus.client;

import io.tus.java.client.TusURLStore;

import java.io.*;
import java.net.URL;
import java.nio.file.*;
import java.util.Properties;

public class FileTusURLStore implements TusURLStore {

    private final Path storePath = Paths.get("tus-upload-urls.properties");

    @Override
    public synchronized void set(String fingerprint, URL url) {
        Properties props = load();
        props.setProperty(fingerprint, url.toString());
        save(props);
    }

    @Override
    public synchronized URL get(String fingerprint) {
        String value = load().getProperty(fingerprint);
        try {
            return value == null ? null : new URL(value);
        } catch (Exception e) {
            return null;
        }
    }

    @Override
    public synchronized void remove(String fingerprint) {
        Properties props = load();
        props.remove(fingerprint);
        save(props);
    }

    private Properties load() {
        Properties props = new Properties();
        if (Files.exists(storePath)) {
            try (InputStream in = Files.newInputStream(storePath)) {
                props.load(in);
            } catch (IOException ignored) {
                // mulai dengan store kosong
            }
        }
        return props;
    }

    private void save(Properties props) {
        try (OutputStream out = Files.newOutputStream(storePath)) {
            props.store(out, "tus upload urls");
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }
}

Enable it with client.enableResuming(new FileTusURLStore()). The default fingerprint is calculated from the file name, size, and modification time, so make sure the file is not changed midway before relying on resume.

Handling Chunks, Offsets, and Metadata

Chunk Size

There is no standard rule, but there are some practical guidelines:

  • Too small (< 256 KB) makes HTTP overhead and TLS handshake dominant. Uploading 1 GB with a 64 KB chunk means more than 16,000 PATCH requests.

  • Too large (> 50 MB) increases the loss if the connection drops in the middle of a chunk, and is easily hit by proxy timeouts.

  • Recommendation: 5–10 MB for stable connections, or 1–2 MB if users are on mobile networks.

Chunk size may change during an upload. The TUS server must not assume all chunks are the same size; what matters is that Upload-Offset is always consistent.

Metadata Format

The Upload-Metadata header contains a comma-separated list of key value pairs. The values are Base64-encoded, while the key must not contain spaces, commas, or control characters.

bashUpload-Metadata: filename dmlkZW8ubXA0,filetype dmlkZW8vbXA0,user_id MTIzNDU=

On the server side, this metadata can be used for validation before the file finishes uploading. Common policy examples:

javaprivate void validasiMetadata(Map<String, String> metadata) {
    String filename = metadata.getOrDefault("filename", "");
    String filetype = metadata.getOrDefault("filetype", "");

    // Cegah path traversal
    if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
        throw new IllegalArgumentException("Nama file tidak valid");
    }

    if (!filetype.startsWith("video/") && !filetype.equals("application/pdf")) {
        throw new IllegalArgumentException("Tipe file tidak diizinkan");
    }

    // Batas ukuran tambahan di luar Tus-Max-Size
    long length = Long.parseLong(metadata.getOrDefault("length", "0"));
    if (length > 500L * 1024 * 1024) {
        throw new IllegalArgumentException("File terlalu besar");
    }
}

If the file size is only known after the upload is underway, use the creation-defer-length extension: send Upload-Defer-Length: 1 during POST, then set Upload-Length on the final PATCH.

Resuming an Interrupted Upload

This is the part most often asked about: how do you know where to continue from? The answer is always the same — ask the server via HEAD, do not trust the offset stored on the client.

The correct resume flow:

  1. The client stores the upload Location (resource URL) together with the file fingerprint.

  2. When the application is opened again or the connection recovers, the client sends HEAD to that URL.

  3. The server replies with Upload-Offset (the byte position safely stored) and Upload-Length.

  4. The client seeks the local file to that offset and sends a PATCH with the same Upload-Offset.

An example of manual implementation on top of tus-java-client, for instance if you want to show a "Resume upload?" dialog to the user:

javapublic void unggahAtauLanjutkan(File file, URL uploadUrl) throws Exception {
    TusClient client = new TusClient();
    client.setUploadCreationURL(new URL("http://localhost:8080/files"));

    TusUpload upload = new TusUpload(file);

    if (uploadUrl != null) {
        // Beri tahu client URL upload yang sudah ada -> resume, bukan buat baru
        upload.setUploadURL(uploadUrl);
    }

    TusUploader uploader = client.resumeOrCreateUpload(upload);
    uploader.setChunkSize(5 * 1024 * 1024);

    System.out.printf("Melanjutkan dari byte %d dari %d%n",
            uploader.getOffset(), upload.getSize());

    boolean selesai = false;
    while (!selesai) {
        try {
            long hasil = uploader.uploadChunk();
            selesai = hasil < 0;
        } catch (IOException e) {
            // Koneksi putus: tunggu sebentar lalu ulangi chunk yang sama.
            // Server akan menolak kalau offset tidak cocok, dan client
            // otomatis menyelaraskan offset dari respons 409.
            System.err.println("Koneksi bermasalah, mencoba lagi: " + e.getMessage());
            Thread.sleep(2000);
        }
    }

    uploader.finish();
    System.out.println("Selesai: " + upload.getUploadURL());
}

Note that retrying here is safe because PATCH is idempotent with respect to the offset: if the chunk was actually already received by the server, the repeated request will be answered with 409 Conflict and the client just needs to align its position.

What also needs to be prepared on the server is an expiration policy. Without it, abandoned uploads will pile up forever. Enable the expiration extension and run a cleanup scheduler:

java@Scheduled(fixedDelay = 60 * 60 * 1000)   // tiap jam
public void bersihkanUploadKedaluwarsa() {
    Instant batas = Instant.now().minus(Duration.ofHours(24));
    int dihapus = uploadStore.hapusYangLebihLamaDari(batas);
    log.info("Menghapus {} upload kedaluwarsa", dihapus);
}

Debugging with cURL

Before blaming the library, test your endpoint with curl. This is the fastest way to separate server bugs from client bugs.

bash# 1. Cek kapabilitas server
curl -i -X OPTIONS http://localhost:8080/files \
  -H "Tus-Resumable: 1.0.0"

# 2. Buat resource upload berukuran 1 MB
curl -i -X POST http://localhost:8080/files \
  -H "Tus-Resumable: 1.0.0" \
  -H "Upload-Length: 1048576" \
  -H "Upload-Metadata: filename dmlkZW8ubXA0,filetype dmlkZW8vbXA0"

# 3. Baca offset saat ini (ganti ID sesuai respons langkah 2)
curl -i -X HEAD http://localhost:8080/files/abc123 \
  -H "Tus-Resumable: 1.0.0"

# 4. Ambil 256 KB mulai dari byte ke-262144, lalu kirim sebagai PATCH
dd if=video.mp4 bs=1 skip=262144 count=262144 of=chunk.bin 2>/dev/null
curl -i -X PATCH http://localhost:8080/files/abc123 \
  -H "Tus-Resumable: 1.0.0" \
  -H "Upload-Offset: 262144" \
  -H "Content-Type: application/offset+octet-stream" \
  --data-binary @chunk.bin

# 5. Uji penanganan konflik: kirim offset yang salah, harus balas 409
curl -i -X PATCH http://localhost:8080/files/abc123 \
  -H "Tus-Resumable: 1.0.0" \
  -H "Upload-Offset: 0" \
  -H "Content-Type: application/offset+octet-stream" \
  --data-binary @chunk.bin

Some common symptoms and their causes:

Symptom

Possible cause

412 Precondition Failed

The Tus-Resumable header was not sent in one of the requests.

415 Unsupported Media Type

Content-Type is not application/offset+octet-stream.

409 Conflict repeats continuously

Client and server offsets never synchronize; usually a chunk is resent without using the offset from the last response.

404 Not Found on PATCH

The upload URL was created with the wrong scheme/host, or the upload ID was deleted due to expiration.

Upload stops at a certain size

Body limit in the reverse proxy, for example client_max_body_size in nginx.

Progress appears to increase but the file ends up corrupt

Data is written in overwrite mode, not append, or there are two simultaneous PATCH operations.

For cross-testing, run tusd as a reference and point your client to it. If the client succeeds but your server fails, the problem is in the server implementation, not the client.

Testing

Write automated tests for three core scenarios: full upload, resume after disconnect, and incorrect offset.

java@SpringBootTest
@AutoConfigureMockMvc
class TusUploadTest {

    @Autowired MockMvc mockMvc;

    @Test
    void resumeSetelahKoneksiTerputus() throws Exception {
        byte[] isiPenuh = new byte[2048];
        new Random(42).nextBytes(isiPenuh);

        // 1. Buat resource upload
        MvcResult dibuat = mockMvc.perform(post("/files")
                        .header("Tus-Resumable", "1.0.0")
                        .header("Upload-Length", isiPenuh.length))
                .andExpect(status().isCreated())
                .andReturn();

        String url = dibuat.getResponse().getHeader("Location");

        // 2. Kirim hanya 1024 byte pertama
        mockMvc.perform(patch(url)
                        .header("Tus-Resumable", "1.0.0")
                        .header("Upload-Offset", 0)
                        .contentType("application/offset+octet-stream")
                        .content(Arrays.copyOfRange(isiPenuh, 0, 1024)))
                .andExpect(status().isNoContent())
                .andExpect(header().string("Upload-Offset", "1024"));

        // 3. Simulasikan aplikasi restart: HEAD harus melaporkan 1024
        mockMvc.perform(head(url).header("Tus-Resumable", "1.0.0"))
                .andExpect(status().isOk())
                .andExpect(header().string("Upload-Offset", "1024"))
                .andExpect(header().string("Upload-Length", "2048"));

        // 4. Lanjutkan sisanya
        mockMvc.perform(patch(url)
                        .header("Tus-Resumable", "1.0.0")
                        .header("Upload-Offset", 1024)
                        .contentType("application/offset+octet-stream")
                        .content(Arrays.copyOfRange(isiPenuh, 1024, 2048)))
                .andExpect(status().isNoContent())
                .andExpect(header().string("Upload-Offset", "2048"));
    }

    @Test
    void offsetSalahHarusBalas409() throws Exception {
        MvcResult dibuat = mockMvc.perform(post("/files")
                        .header("Tus-Resumable", "1.0.0")
                        .header("Upload-Length", 1024))
                .andExpect(status().isCreated())
                .andReturn();

        String url = dibuat.getResponse().getHeader("Location");

        mockMvc.perform(patch(url)
                        .header("Tus-Resumable", "1.0.0")
                        .header("Upload-Offset", 500)
                        .contentType("application/offset+octet-stream")
                        .content(new byte[100]))
                .andExpect(status().isConflict())
                .andExpect(header().string("Upload-Offset", "0"));
    }
}

For end-to-end testing from the browser side, use tus-js-client and cut the connection midway using DevTools throttling. Killing the connection at 50% and then bringing it back is a scenario that must pass before release.

Best Practices

  • Do not store the offset only on the client. The server is the source of truth. Always HEAD before resuming.

  • Store upload metadata in a database. If you run on more than one node, a companion file on local disk will not be visible to other nodes. Record upload_id, offset, length, metadata, and expiration time in a dedicated table.

  • Enable Tus-Max-Size. Limit upload size at the protocol level so an attacker cannot exhaust your disk with a single large request.

  • Validate metadata early. Do it during POST, not when the upload finishes. It is better to reject in the first second than after 2 GB has been sent.

  • Secure the endpoint. TUS does not govern authentication; send a token in the Authorization header and verify before POST/PATCH is processed.

  • Clean up expired uploads. Run a scheduler that deletes resources untouched for 24 hours.

  • Use the checksum extension. Send the Upload-Checksum header at the end of the upload so the server can verify data integrity.

  • Configure the reverse proxy correctly. This is the most frequent cause of production failures.

nginxlocation /files {
    proxy_pass http://backend;
    proxy_http_version 1.1;

    # Jangan buffer body di nginx - teruskan langsung ke aplikasi
    proxy_request_buffering off;
    proxy_buffering off;

    # Nonaktifkan batas body di nginx; batas tetap dijaga Tus-Max-Size
    client_max_body_size 0;

    # Upload besar butuh timeout panjang
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;

    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

If you are behind a load balancer, also make sure X-Forwarded-Proto and X-Forwarded-Host are forwarded, because the Location header returned by the server is built from that information. Misconfiguration here will make the client send PATCH to an unreachable URL.

Conclusion

The TUS protocol looks simple — just a few headers and four HTTP methods — but it solves one of the most annoying problems in file applications: uploads that must be repeated from scratch. In Java, the combination of tus-java-server for the backend and tus-java-client for the uploader side makes the implementation quite short, while important details such as offset handling and resume can still be controlled yourself.

Next steps you can work on: replace the default state storage with a database table so it can run multi-node, add JWT authentication to the TUS endpoint, and integrate tus-js-client in the frontend so users can immediately see upload progress in the browser.