Implementing Resumable File Upload with Tus Protocol in Spring Boot Without Libraries
Oct 2026 · 11 min read
A guide to manually implementing a basic subset of the Tus protocol in Spring Boot, including POST, HEAD, and PATCH endpoints, file storage, metadata, and integration with tus-js-client with pause and resume features.
Large file uploads often fail midway due to connection drops, and users have to restart from the beginning. The Tus protocol provides a resumable upload mechanism based on HTTP so that clients can continue sending from the last offset known by the server. This article discusses the manual implementation used in the tus-manual project: basic Tus endpoints, file storage, metadata handling, and integration of a Thymeleaf page with tus-js-client version 4. Link repo: https://github.com/Sch39/java-spring-boot
This implementation is intended as a learning example and only covers the core flow of POST, HEAD, and PATCH. It cannot be considered a complete or production-ready Tus server because it lacks discovery via OPTIONS, metadata persistence, final length validation, cleanup, checksum, authentication, and complete protocol error handling.
Basic Concepts of the Tus Protocol
Tus is an open protocol for resumable file uploads. It works over HTTP and defines upload resources along with custom headers so that client and server agree on the next byte position to send.
The main flow used in this project:
Creation: the client sends
POST /fileswithUpload-Length. The server creates a new resource and returns aLocationheader containing a unique upload URL.Head: the client sends
HEAD /files/{id}to read theUpload-OffsetandUpload-Lengthknown by the server.PATCH: the client sends a data chunk with
Content-Type: application/offset+octet-streamandUpload-Offset. The server writes data starting from that position and then returns the new offset.Metadata: the client can send
Upload-Metadatain the form of key and Base64 value pairs separated by commas.
With this flow, an interrupted request does not require the client to send the entire file from the beginning. However, the resume capability in this implementation depends on server state still stored in memory and the upload instance still available on the browser page.
Dependency Choices
There are two common approaches to implementing Tus in Spring Boot:
Using a Tus server library: provides most protocol details and is usually more suitable for production needs.
Manual implementation: write your own controller and service to understand the upload resource flow, offset, metadata, and chunk writing.
This project uses a manual implementation and has no Tus server dependency. The dependencies used are Spring Boot Web, Thymeleaf, and standard testing dependencies. The project configuration uses Spring Boot 4.1.1 and Java 21.
<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>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Spring Boot Project Setup
The application code uses the package dev.sch39.tus_manual with the following main structure:
src/main/java/dev/sch39/tus_manual/
├── controller/
│ ├── TusUploadController.java
│ └── UploadPageController.java
├── service/
│ └── TusUploadService.java
└── TusManualApplication.java
src/main/resources/
├── static/js/
│ └── upload.js
├── templates/
│ └── upload.html
└── application.properties
UploadPageController provides the upload page via GET /upload:
package dev.sch39.tus_manual.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class UploadPageController {
@GetMapping("/upload")
public String uploadPage() {
return "upload";
}
}
Tus Endpoint Configuration
The endpoints available in this implementation:
POST /files— creates a new upload resource.HEAD /files/{id}— reads the offset and upload length.PATCH /files/{id}— sends data chunks.GET /upload— displays the Thymeleaf-based upload page.
The storage directory is configured via application.properties:
spring.application.name=tus-manual
tus.upload-dir=./uploads
The path ./uploads is relative to the JVM working directory. Uploaded files are stored using a UUID as the filename, without the original extension. The filename and MIME type from the browser are only stored as metadata in memory.
Service Implementation
The service manages file creation, upload state, and chunk writing. The following is the actual implementation of TusUploadService:
package dev.sch39.tus_manual.service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class TusUploadService {
@Value("${tus.upload-dir}")
private String uploadDir;
private final Map<String, UploadInfo> uploads = new ConcurrentHashMap<>();
public String createUpload(long length, Map<String, String> metadata) throws IOException {
String id = UUID.randomUUID().toString();
Path filePath = Paths.get(uploadDir, id);
Files.createDirectories(filePath.getParent());
Files.createFile(filePath);
UploadInfo info = new UploadInfo(id, filePath, length, metadata);
uploads.put(id, info);
return id;
}
public long getOffset(String id) {
UploadInfo info = uploads.get(id);
if (info == null)
throw new IllegalArgumentException("Upload not found");
return info.offset;
}
public long appendData(String id, byte[] data, long offset) throws IOException {
UploadInfo info = uploads.get(id);
if (info == null)
throw new IllegalArgumentException("Upload not found");
if (offset != info.offset)
throw new IllegalStateException("Offset mismatch");
try (RandomAccessFile raf = new RandomAccessFile(info.path.toFile(), "rw")) {
raf.seek(offset);
raf.write(data);
}
info.offset += data.length;
return info.offset;
}
public UploadInfo getUploadInfo(String id) {
return uploads.get(id);
}
public static class UploadInfo {
public final String id;
public final Path path;
public final long length;
public final Map<String, String> metadata;
public long offset = 0;
public UploadInfo(String id, Path path, long length, Map<String, String> metadata) {
this.id = id;
this.path = path;
this.length = length;
this.metadata = metadata;
}
}
}
Purpose: Provides operations to create an upload resource, read state, check offset consistency, and write chunks using RandomAccessFile.
The ConcurrentHashMap map makes basic map operations safe when accessed by multiple threads, but it does not make the sequence of offset checking and file writing atomic. Two concurrent PATCH requests with the same offset can still pass the check and overwrite the same file area. For production use, a per-upload lock or other synchronization mechanism is needed.
State such as Upload-Length, metadata, and offset is only stored in memory. File bytes remain on disk after the application stops, but uploads cannot be resumed after a restart because the uploads map becomes empty again.
Controller Implementation
The controller handles HTTP requests for the basic subset of the Tus protocol. The following is the actual implementation of TusUploadController:
package dev.sch39.tus_manual.controller;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import dev.sch39.tus_manual.service.TusUploadService;
import java.io.IOException;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/files")
public class TusUploadController {
private final TusUploadService service;
public TusUploadController(TusUploadService service) {
this.service = service;
}
@PostMapping
public ResponseEntity<Void> createUpload(
@RequestHeader("Upload-Length") long length,
@RequestHeader(value = "Upload-Metadata", required = false) String metadataHeader) throws IOException {
Map<String, String> metadata = parseMetadata(metadataHeader);
String id = service.createUpload(length, metadata);
HttpHeaders headers = new HttpHeaders();
headers.add("Location", "/files/" + id);
headers.add("Tus-Resumable", "1.0.0");
return new ResponseEntity<>(headers, HttpStatus.CREATED);
}
@RequestMapping(value = "/{id}", method = RequestMethod.HEAD)
public ResponseEntity<Void> headUpload(@PathVariable String id) {
var info = service.getUploadInfo(id);
if (info == null) {
return ResponseEntity.notFound().build();
}
HttpHeaders headers = new HttpHeaders();
headers.set("Upload-Offset", String.valueOf(info.offset));
headers.set("Upload-Length", String.valueOf(info.length));
headers.set("Tus-Resumable", "1.0.0");
headers.set("Cache-Control", "no-store");
return new ResponseEntity<>(headers, HttpStatus.OK);
}
@PatchMapping(value = "/{id}", consumes = "application/offset+octet-stream")
public ResponseEntity<Void> patchUpload(
@PathVariable String id,
@RequestHeader("Upload-Offset") long offset,
@RequestBody byte[] data) throws IOException {
long newOffset = service.appendData(id, data, offset);
HttpHeaders headers = new HttpHeaders();
headers.add("Upload-Offset", String.valueOf(newOffset));
headers.add("Tus-Resumable", "1.0.0");
return new ResponseEntity<>(headers, HttpStatus.NO_CONTENT);
}
private Map<String, String> parseMetadata(String header) {
Map<String, String> metadata = new HashMap<>();
if (header == null || header.isBlank())
return metadata;
for (String pair : header.split(",")) {
String[] parts = pair.trim().split(" ", 2);
String key = parts[0];
String value = parts.length > 1 ? new String(Base64.getDecoder().decode(parts[1])) : "";
metadata.put(key, value);
}
return metadata;
}
}
Purpose: Provides upload resource creation, offset reading, and chunk sending.
Endpoint behavior in more detail:
POST /filesrequiresUpload-Length, acceptsUpload-Metadataoptionally, and returns201 Created,Location: /files/{uuid}, andTus-Resumable: 1.0.0.HEAD /files/{id}returns200 OKalong withUpload-Offset,Upload-Length,Tus-Resumable, andCache-Control: no-store. An ID that is not found explicitly results in404 Not Found.PATCH /files/{id}only acceptsapplication/offset+octet-stream, reads the entire chunk asbyte[], and then returns204 No Content, the new offset, and the Tus version.
The controller does not yet validate the Tus-Resumable header from the request. Location is also still a relative path. In addition, exceptions from a not-found upload during PATCH, mismatched offset, invalid Base64, and I/O are not yet mapped via @ControllerAdvice to specific Tus responses.
File Metadata Handling
Metadata is sent via the Upload-Metadata header in the format key base64value separated by commas. Example:
Upload-Metadata: filename dGVzdC5wZGY=,filetype YXBwbGljYXRpb24vcGRm
The Base64 values represent test.pdf and application/pdf. The parser splits each pair on commas, then separates key and value at the first space. A key without a value is stored as an empty string.
In the current implementation, metadata is only stored inside UploadInfo while the application is running. Metadata is not yet used to rename files, validate MIME type, audit, or persist. The decoder also uses the platform default charset via new String(...), so a stricter implementation should explicitly use UTF-8 and validate metadata format and size.
Integration with Frontend (tus-js-client)
The upload.html page loads the tus-js-client version 4 browser bundle from jsDelivr, then runs /js/upload.js:
<!-- Browser bundle tus-js-client -->
<script src="https://cdn.jsdelivr.net/npm/tus-js-client@4/dist/tus.min.js"></script>
<!-- Logika upload -->
<script src="/js/upload.js"></script>
The actual upload configuration on the frontend:
function createUpload(file) {
isPaused = false;
isFinished = false;
isFailed = false;
upload = new tus.Upload(file, {
endpoint: '/files',
retryDelays: [0, 1000, 3000, 5000],
chunkSize: 5 * 1024 * 1024,
metadata: {
filename: file.name,
filetype: file.type || 'application/octet-stream'
},
onError(error) {
isFailed = true;
status.textContent = `Upload gagal: ${error.message}`;
console.error('TUS upload error:', error);
updateButtons();
},
onProgress(bytesUploaded, bytesTotal) {
const percent = bytesTotal > 0
? (bytesUploaded / bytesTotal) * 100
: 0;
progress.value = percent;
percentage.textContent = `${percent.toFixed(2)}%`;
status.textContent =
`Terupload ${bytesUploaded} dari ${bytesTotal} bytes`;
},
onSuccess() {
isFinished = true;
isPaused = false;
progress.value = 100;
percentage.textContent = '100%';
status.textContent = 'Upload berhasil diselesaikan!';
console.log('Upload selesai:', upload.url);
updateButtons();
}
});
updateButtons();
}
Each chunk request from the browser is at most 5 MiB. That size is a client choice and is not re-limited by the server. Because the controller accepts @RequestBody byte[], the entire chunk is buffered in heap before being written to disk.
The pause feature calls and waits for upload.abort(), while resume calls upload.start() on the same instance:
pauseButton.addEventListener('click', async () => {
if (!upload || isPaused || isFinished) {
return;
}
await upload.abort();
isPaused = true;
status.textContent = 'Upload dijeda. Tekan Resume untuk melanjutkan.';
updateButtons();
});
resumeButton.addEventListener('click', () => {
if (!upload || !isPaused || isFinished) {
return;
}
isPaused = false;
status.textContent = 'Melanjutkan upload...';
updateButtons();
upload.start();
});
abort() here only stops the client request and does not delete the resource on the server. Resume works on the same page because the tus.Upload instance and upload URL are still available. The code does not yet use findPreviousUploads() and resumeFromPreviousUpload(), so resume after page reload or browser close is not yet implemented.
Testing
The application can be opened via:
http://localhost:8080/upload
Endpoints can also be tested manually with curl:
# Membuat upload baru
curl -X POST http://localhost:8080/files \
-H "Upload-Length: 11" \
-H "Upload-Metadata: filename dGVzdC50eHQ=" \
-i
# Cek offset dan panjang upload
curl -I http://localhost:8080/files/<id>
# Kirim chunk
curl -X PATCH http://localhost:8080/files/<id> \
-H "Upload-Offset: 0" \
-H "Content-Type: application/offset+octet-stream" \
--data-binary "hello world" -i
Expected main output:
HTTP/1.1 201 Created
Location: /files/550e8400-e29b-41d4-a716-446655440000
Tus-Resumable: 1.0.0
HTTP/1.1 200 OK
Upload-Offset: 0
Upload-Length: 11
Tus-Resumable: 1.0.0
Cache-Control: no-store
HTTP/1.1 204 No Content
Upload-Offset: 11
Tus-Resumable: 1.0.0
The project's automated tests currently only consist of @SpringBootTest with contextLoads(). There are no tests for the controller, file writing, offset mismatch, invalid metadata, restart, chunks exceeding the length, or concurrent PATCH.
Why It Works
Each upload gets a unique UUID-based resource and an empty file in the
uploadsdirectory.The server stores the current offset and only accepts a chunk if
Upload-Offsetequals that offset.RandomAccessFile.seek(offset)positions the write at the agreed byte.After the chunk is written, the server returns the new offset so the client knows the next position.
tus-js-clienthandles creation, 5 MiB chunk sending, retry, progress, pause, and resume on the same upload instance.Metadata allows the server to store initial information such as name and MIME type, although in this implementation metadata is not processed further.
It should be noted that the onSuccess callback comes from client behavior. The server does not compare the final offset with Upload-Length, does not mark the upload as complete, and does not prevent additional PATCH requests after the declared length is reached.
Edge Cases
Offset mismatch: the service throws
IllegalStateException, but there is no specific mapping to an appropriate conflict status.Upload not found:
HEADreturns404, whilePATCHstill throwsIllegalArgumentExceptionwithout specific mapping.Connection dropped:
tus-js-clientretries according toretryDelaysand can continue the same instance from the server offset.Browser reload or restart: there is no integration of
findPreviousUploads()andresumeFromPreviousUpload().Server restart: files remain on disk, but the in-memory upload state is lost so the resource cannot be resumed.
Invalid metadata: incorrect Base64 is not handled as an explicit
4xxresponse.Upload length: negative values, chunks exceeding
Upload-Length, andPATCHafter completion are not rejected.Large chunks: the server reads the entire body as
byte[]; there is no streaming or chunk limit configured on the application side.Concurrent requests: there is no per-upload lock, so concurrent
PATCHcan cause race conditions on the file and offset.Temporary storage: there is no TTL, cleanup scheduler, termination endpoint, or deletion of abandoned files.
Security and integrity: there is no authentication, upload ownership authorization, quota, checksum, MIME type validation, or disk capacity check.
Protocol completeness:
OPTIONS, termination, expiration, checksum, concatenation, creation-with-upload, and deferred upload length are not yet available.
Conclusion
This manual implementation demonstrates the core mechanism of resumable upload: creating a resource with POST, reading the offset with HEAD, and appending data via PATCH. Spring Boot handles HTTP routing, while RandomAccessFile writes chunks at the agreed offset. On the frontend, tus-js-client version 4 provides retry, progress, pause, and resume on the same upload instance.
As a learning example, this code is sufficient to show the basic Tus flow. For production, upload state needs to be persisted, writes need to be synchronized, the body should be streamed, the final length must be validated, errors need to be mapped to the correct protocol statuses, and the resource lifecycle needs to be completed with authentication, cleanup, and adequate automated testing.