Resume file uploads in Java with tus-java-client
A failed upload request should not force you to resend the whole file. This walkthrough uses
tus-java-client to upload a binary file, recover from a deliberate server failure, and verify the
stored bytes. You will run both sides locally and see the retry start at the server’s saved offset.
Setting up tus-java-client
The commands below target Linux with Bash, OpenJDK 21.0.12.1, Maven 3.9.16, Node.js 24.15.0, Corepack, and Yarn 4.12.0. These are the tested tool versions. Node runs the local test server; your Java application does not need it when connecting to an existing tus endpoint.
Create the project outside an existing application. The empty lockfile gives Yarn its own project
boundary, and the local configuration selects node_modules for the server dependencies. This block
leaves your terminal in its original directory and refuses an existing
java-tus-demo directory:
(
mkdir java-tus-demo &&
cd java-tus-demo &&
mkdir -p src/main/java &&
touch yarn.lock &&
printf 'nodeLinker: node-modules\n' > .yarnrc.yml
)
Open java-tus-demo in your editor and use it as the working directory in two terminals. Save this
complete pom.xml at its root:
<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>
<groupId>example</groupId>
<artifactId>java-tus-demo</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>io.tus.java.client</groupId>
<artifactId>tus-java-client</artifactId>
<version>0.5.1</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.9.0</version>
</plugin>
</plugins>
</build>
</project>
Start a local tus server
Save package.json beside the POM:
{
"name": "java-tus-demo",
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"dependencies": {
"@tus/file-store": "2.1.1",
"@tus/server": "2.4.5"
}
}
Save the following as server.ts. It uses the official
Node tus server and disk store.
The failure switch rejects one upload request once its starting offset reaches 1 MiB; the earlier
bytes stay on disk. Leave it enabled for the first run.
import { createServer } from 'node:http'
import { FileStore } from '@tus/file-store'
import { Server } from '@tus/server'
const port = Number(process.argv[2] ?? '1080')
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error('Port must be an integer from 0 to 65535')
}
const tus = new Server({
path: '/files',
datastore: new FileStore({ directory: './uploads' }),
})
let failOnce = true
const http = createServer((request, response) => {
// Java's HttpURLConnection uses this override when it cannot send PATCH.
if (request.method === 'POST' && request.headers['x-http-method-override'] === 'PATCH') {
request.method = 'PATCH'
}
response.on('finish', () => {
console.log(`${request.method} ${request.url} -> ${response.statusCode}, request offset=${request.headers['upload-offset'] ?? '-'}`)
})
if (failOnce && request.method === 'PATCH' && Number(request.headers['upload-offset']) >= 1048576) {
failOnce = false
request.resume()
response.writeHead(503, { 'Tus-Resumable': '1.0.0' })
response.end('Deliberate interruption')
return
}
void tus.handle(request, response).catch((error: unknown) => {
console.error(error)
response.destroy()
})
})
http.on('error', (error) => {
console.error(`Server failed: ${error.message}`)
process.exitCode = 1
})
http.listen(port, '127.0.0.1', () => {
const address = http.address()
if (address && typeof address !== 'string') {
console.log(`Listening at http://127.0.0.1:${address.port}/files`)
}
})
The override is necessary with the tested Java transport: the
0.5.1 uploader
falls back to POST with X-HTTP-Method-Override: PATCH. Forwarding that request as an ordinary
creation request would break the upload.
In the first terminal, install the server dependencies and start it:
corepack yarn install && corepack yarn node server.ts 1080
Wait for the listening URL. If port 1080 is occupied, choose another port in this command and in the Java command below. The server binds only to loopback and has no authentication. Keep it local; it is a learning fixture, not a deployment configuration. Stop it with Ctrl+C when finished.
Basic usage
Save this complete class as src/main/java/TusUploaderExample.java:
import io.tus.java.client.ProtocolException;
import io.tus.java.client.TusClient;
import io.tus.java.client.TusExecutor;
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.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
public class TusUploaderExample {
public static void main(String[] args) {
try {
run(args);
} catch (Exception error) {
System.err.println("Upload failed: " + error.getMessage());
System.exit(1);
}
}
private static void run(String[] args) throws Exception {
if (args.length != 2) {
throw new IllegalArgumentException("Usage: TusUploaderExample ENDPOINT FILE");
}
File file = new File(args[1]);
if (!file.isFile() || !file.canRead()) {
throw new IOException("Input must be a readable regular file: " + file);
}
List<HttpURLConnection> connections = new ArrayList<>();
TusClient client = new TusClient() {
@Override
public void prepareConnection(HttpURLConnection connection) {
super.prepareConnection(connection);
connection.setReadTimeout(5000);
connections.add(connection);
}
};
client.setConnectTimeout(5000);
client.setUploadCreationURL(URI.create(args[0]).toURL());
client.enableResuming(new TusURLMemoryStore());
TusExecutor executor = new TusExecutor() {
private int attempt = 0;
@Override
protected void makeAttempt() throws IOException, ProtocolException {
System.out.println("Attempt " + ++attempt);
TusUpload upload = new TusUpload(file);
URL completedUrl;
// Close the file even if creation, resuming, or finish() fails.
try (var input = upload.getInputStream()) {
TusUploader uploader = client.resumeOrCreateUpload(upload);
uploader.setChunkSize(64 * 1024);
uploader.setRequestPayloadSize(1024 * 1024);
System.out.println("Starting at byte " + uploader.getOffset());
try {
while (uploader.uploadChunk() != -1) {
// The server response is checked when the request finishes.
}
} finally {
uploader.finish();
}
completedUrl = uploader.getUploadURL();
} finally {
for (HttpURLConnection connection : connections) {
connection.disconnect();
}
connections.clear();
}
System.out.println("Completed: " + completedUrl);
}
};
executor.setDelays(new int[] {500, 1000});
if (!executor.makeAttempts()) {
throw new IOException("Upload interrupted while waiting to retry");
}
}
}
Each attempt reopens the same file and shares one client and URL store. resumeOrCreateUpload()
creates the upload on the first attempt; on retry it uses HEAD to obtain the server’s offset.
Keep the source file unchanged throughout the transfer. The library’s file fingerprint includes
its path and length, not a content hash.
The two size settings do different jobs. The 64 KiB chunk is the client’s copy buffer; the 1 MiB payload setting caps each HTTP upload request. These deliberately small requests make the interruption visible. They are not throughput recommendations.
In the second terminal, create a binary sample slightly larger than two requests. This command
refuses to overwrite an existing sample.bin:
corepack yarn node -e "require('node:fs').writeFileSync('sample.bin', require('node:crypto').randomBytes(2 * 1024 * 1024 + 4099), {flag: 'wx'})"
Compile, copy the runtime dependencies, and run the class. The && prevents an unsuccessful build
from running stale classes. The classpath separator here is for Linux:
mvn -q compile dependency:copy-dependencies &&
java -cp 'target/classes:target/dependency/*' TusUploaderExample http://127.0.0.1:1080/files sample.bin
Advanced features
Read the retry evidence
With a freshly started server, the Java output should be:
Attempt 1
Starting at byte 0
Attempt 2
Starting at byte 1048576
Completed: http://127.0.0.1:1080/files/<upload-id>
In the server terminal, find the successful upload request starting at offset 0, the deliberate
503 at 1048576, and the subsequent HEAD. The Java retry’s starting byte comes from that
HEAD response. The remaining successful requests start at 1048576 and 2097152. This follows the
tus offset protocol: the server tells the
client where to continue. The first MiB does not need to be sent again.
Replace UPLOAD_ID below with the final path segment of the completed URL. Compare the actual
stored file in the second terminal:
cmp sample.bin uploads/UPLOAD_ID && printf 'Files match\n'
The completion message means the server acknowledged the transfer. cmp separately checks the
bytes on disk. It reports a mismatch or missing file with a nonzero exit status.
Understand what survives an interruption
TusURLMemoryStore retains the upload URL only while this Java process is alive. The simulated
failure leaves that process running. Stopping Java with Ctrl+C and launching it again creates a
new upload; this example does not implement restart recovery. A persistent
TusURLStore
and a reliable file-identity policy are needed for that integration.
The server keeps files in uploads/ after it stops. Re-running Java creates a new randomly named
server file, leaving earlier uploads intact. Remove those local test files when you no longer need
them. Restart the server to re-arm its one-time failure, or set failOnce to false to disable it.
Best practices
The example allows at most three attempts: the initial call and two retries after 500 ms and
1,000 ms. Those bounds come from
TusExecutor.setDelays().
Connect and read timeouts limit individual waits; they are not an overall upload deadline.
An empty file is valid and completes without a percentage calculation. A missing file fails before
contacting the server. If the server remains unavailable, Java prints a failure and exits nonzero
after exhausting retries. Non-retryable protocol errors can fail sooner. Check that exit status in
scripts; a progress offset alone does not establish completion, because finish() can still reject
the final response.
Platform compatibility
This walkthrough covers a desktop Java process on Linux. The Java library also supports Android, but this program does not handle Android lifecycle events or background scheduling. For that separate integration, start with tus-android-client.
