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
Locationthat can be used multiple times.The server always knows the last byte position. The
Upload-Offsetheader is the single source of truth.The operations are idempotent. Resending the same chunk or performing a
HEADdoes 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:
OPTIONS— asks for server capabilities (version, extensions, maximum size).POST— creates a new upload resource, returnsLocation.PATCH— sends the next data piece.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 |
|---|---|---|
| Request & Response | Protocol version, currently |
| Request & Response | Current byte position in the upload resource. |
| Request | Total file size in bytes, sent when creating the upload. |
| Request | Key-value pairs; the values are Base64-encoded. |
| Request | Set to |
| Response | Server capabilities, sent during |
| 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: 1048576Two rules most often causing bugs:
PATCHmust useContent-Type: application/offset+octet-stream. If not, the server returns415 Unsupported Media Type.The
Upload-Offsetin the request must exactly match the offset stored on the server. If not, the response is409 Conflictalong with the correctUpload-Offsetheader.
TUS Library Options in Java
Instead of writing the protocol from scratch, there are two official libraries you can use:
Library | Side | Suited for |
|---|---|---|
| Server | A server implementation based on the Servlet API. Can be installed in standalone Tomcat/Jetty, Spring Boot MVC, or a regular Servlet application. |
| Client | Java desktop, CLI, worker, or service-to-service applications that need to upload files to a TUS endpoint. |
| 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-serverversion that already supports Jakarta EE 9+, orTemporarily downgrade to Spring Boot 2.7 (which still uses
javax.servlet), orRun 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 theLocationheader.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 thePATCHbody 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
PATCHrequests.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:
The client stores the upload
Location(resource URL) together with the file fingerprint.When the application is opened again or the connection recovers, the client sends
HEADto that URL.The server replies with
Upload-Offset(the byte position safely stored) andUpload-Length.The client seeks the local file to that offset and sends a
PATCHwith the sameUpload-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.binSome common symptoms and their causes:
Symptom | Possible cause |
|---|---|
| The |
|
|
| Client and server offsets never synchronize; usually a chunk is resent without using the offset from the last response. |
| 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 |
Progress appears to increase but the file ends up corrupt | Data is written in overwrite mode, not append, or there are two simultaneous |
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
HEADbefore 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
Authorizationheader and verify beforePOST/PATCHis processed.Clean up expired uploads. Run a scheduler that deletes resources untouched for 24 hours.
Use the checksum extension. Send the
Upload-Checksumheader 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.