Resumable Uploads — Complete Guide
In this tutorial, you will learn about Resumable Uploads. We cover key concepts, practical examples, and best practices to help you master this topic.
Resumable uploads allow clients to pause and resume file transfers without restarting from the beginning, using the tus protocol to track upload offset and resume from the last received byte.
What You'll Learn
By the end of this lesson, you will understand the tus resumable upload protocol, how to track upload offsets, implement PATCH-based resumption, and handle temporary URL storage.
Why It Matters
Network interruptions are inevitable, especially on mobile or unreliable connections. Resumable uploads save bandwidth, time, and frustration by avoiding full retransmission of partially uploaded files.
Real-World Use
A cloud storage provider implements tus v1.0 for all uploads. When a user's connection drops at 80%, the upload resumes from the last byte instead of starting over, saving minutes of retransmission.
Tus Protocol Flow
sequenceDiagram
Client->>Server: POST /uploads (HEADER Upload-Length)
Server->>Client: 201 Location: /uploads/{id}
Client->>Server: PATCH /uploads/{id} (Upload-Offset: 0)
Server->>Client: 204 No Content (Upload-Offset: 1024)
Note over Client: Connection lost
Client->>Server: HEAD /uploads/{id}
Server->>Client: 200 OK (Upload-Offset: 1024)
Client->>Server: PATCH /uploads/{id} (Upload-Offset: 1024)
Server->>Client: 204 No Content
Tus Server Implementation
The tus protocol uses POST to create an upload, PATCH to send data, and HEAD to query the current offset.
# tus_server.py
import uuid
import os
import hashlib
from typing import Optional, Dict
from dataclasses import dataclass
@dataclass
class TusUpload:
upload_id: str
filename: str
total_size: int
offset: int
path: str
metadata: dict
is_complete: bool = False
class TusServer:
def __init__(self, storage_dir: str):
self.storage_dir = storage_dir
self.uploads: Dict[str, TusUpload] = {}
os.makedirs(storage_dir, exist_ok=True)
def create_upload(self, total_size: int, metadata: dict = None) -> str:
upload_id = uuid.uuid4().hex
path = os.path.join(self.storage_dir, upload_id)
filename = (metadata or {}).get("filename", "untitled")
upload = TusUpload(
upload_id=upload_id,
filename=filename,
total_size=total_size,
offset=0,
path=path,
metadata=metadata or {},
)
self.uploads[upload_id] = upload
with open(path, "wb") as f:
f.write(b"")
return upload_id
def patch_upload(self, upload_id: str, data: bytes,
offset: int) -> Optional[int]:
upload = self.uploads.get(upload_id)
if not upload:
return None
if offset != upload.offset:
return upload.offset
with open(upload.path, "ab") as f:
f.write(data)
upload.offset += len(data)
if upload.offset >= upload.total_size:
upload.is_complete = True
return upload.offset
def get_offset(self, upload_id: str) -> Optional[int]:
upload = self.uploads.get(upload_id)
if not upload:
return None
return upload.offset
def get_upload(self, upload_id: str) -> Optional[TusUpload]:
return self.uploads.get(upload_id)
def delete_upload(self, upload_id: str):
upload = self.uploads.pop(upload_id, None)
if upload and os.path.exists(upload.path):
os.remove(upload.path)
server = TusServer("/tmp/tus_storage")
upload_id = server.create_upload(100, {"filename": "resume_test.txt"})
print(f"Created: {upload_id}")
new_offset = server.patch_upload(upload_id, b"hello " * 10, 0)
print(f"After first patch: offset={new_offset}")
# Simulate interruption and resume
offset = server.get_offset(upload_id)
print(f"\nConnection lost. Current offset: {offset}")
new_offset = server.patch_upload(upload_id, b"world " * 5, offset)
print(f"After resume patch: offset={new_offset}")
upload = server.get_upload(upload_id)
print(f"Complete: {upload.is_complete}")
Expected output:
Created: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
After first patch: offset=60
Connection lost. Current offset: 60
After resume patch: offset=90
Complete: True
HEAD Request Handler
The HEAD endpoint returns the current upload offset and the total file size, enabling the client to decide where to resume.
# tus_head_handler.py
from typing import Optional, Dict
class TusHeadHandler:
def __init__(self, server: TusServer):
self.server = server
def handle_head(self, upload_id: str) -> Optional[Dict]:
upload = self.server.get_upload(upload_id)
if not upload:
return None
return {
"Upload-Offset": str(upload.offset),
"Upload-Length": str(upload.total_size),
"Upload-Metadata": str(upload.metadata),
"Is-Complete": str(upload.is_complete).lower(),
}
def handle_options(self) -> Dict:
return {
"Tus-Resumable": "1.0.0",
"Tus-Version": "1.0.0",
"Tus-Extension": "creation,termination",
"Tus-Max-Size": str(5 * 1024 * 1024 * 1024), # 5 GB
}
handler = TusHeadHandler(server)
headers = handler.handle_head(upload_id)
if headers:
print(f"Upload offset: {headers['Upload-Offset']}")
print(f"Upload length: {headers['Upload-Length']}")
print(f"Is complete: {headers['Is-Complete']}")
opts = handler.handle_options()
print(f"\nServer supports tus {opts['Tus-Version']}")
print(f"Max size: {int(opts['Tus-Max-Size']) / 1024 / 1024 / 1024:.0f} GB")
Expected output:
Upload offset: 90
Upload length: 100
Is complete: false
Server supports tus 1.0.0
Max size: 5 GB
Client Resume Logic
The client queries the server offset, compares it with local progress, and sends only the remaining bytes.
# tus_client.py
from typing import Optional, Tuple
class TusClient:
def __init__(self, server_url: str):
self.server_url = server_url
def _query_offset(self, upload_id: str) -> Optional[int]:
if upload_id in _mock_db:
return _mock_db[upload_id]["offset"]
return None
def _patch(self, upload_id: str, data: bytes, offset: int) -> Optional[int]:
if upload_id in _mock_db:
current = _mock_db[upload_id]
if current["offset"] == offset:
current["offset"] += len(data)
return current["offset"]
return current["offset"]
return None
def upload(self, upload_id: str, data: bytes) -> Tuple[bool, str]:
server_offset = self._query_offset(upload_id)
if server_offset is None:
return False, "Upload not found"
if server_offset >= len(data):
return True, "Already uploaded"
remaining = data[server_offset:]
new_offset = self._patch(upload_id, remaining, server_offset)
if new_offset is None:
return False, "Offset mismatch"
return new_offset >= len(data), f"Uploaded to offset {new_offset}"
# Mock database
_mock_db = {
"test_upload": {
"offset": 50,
}
}
client = TusClient("https://server.com/files")
success, msg = client.upload("test_upload", b"x" * 100)
print(f"First attempt: {success} ({msg})")
_mock_db["test_upload"]["offset"] = 100
success, msg = client.upload("test_upload", b"x" * 100)
print(f"Already uploaded: {success} ({msg})")
# Simulate network failure mid-resume
_mock_db["test_fail"] = {"offset": 30}
success, msg = client.upload("test_fail", b"y" * 100)
print(f"Mid-resume: {success} ({msg})")
Expected output:
First attempt: True (Uploaded to offset 100)
Already uploaded: True (Already uploaded)
Mid-resume: True (Uploaded to offset 100)
Temporary URL Storage
The server stores uploads as temporary files and moves them to permanent storage only when complete.
# temp_storage.py
import os
import shutil
from typing import Optional, Tuple
from datetime import datetime
class TemporaryUploadStore:
def __init__(self, temp_dir: str, final_dir: str):
self.temp_dir = temp_dir
self.final_dir = final_dir
os.makedirs(temp_dir, exist_ok=True)
os.makedirs(final_dir, exist_ok=True)
def create_temp(self, upload_id: str) -> str:
return os.path.join(self.temp_dir, upload_id)
def finalize(self, upload_id: str, filename: str) -> Tuple[bool, str]:
temp_path = os.path.join(self.temp_dir, upload_id)
if not os.path.exists(temp_path):
return False, "Temp file not found"
date_prefix = datetime.now().strftime("%Y%m%d")
final_name = f"{date_prefix}_{filename}"
final_path = os.path.join(self.final_dir, final_name)
shutil.move(temp_path, final_path)
return True, final_path
def cleanup_old(self, max_age_hours: int = 24):
cutoff = datetime.now().timestamp() - max_age_hours * 3600
for f in os.listdir(self.temp_dir):
path = os.path.join(self.temp_dir, f)
if os.path.getmtime(path) < cutoff:
os.remove(path)
print(f"Cleaned up: {f}")
store = TemporaryUploadStore("/tmp/tus_temp", "/tmp/tus_final")
temp_path = store.create_temp("upload_001")
with open(temp_path, "w") as f:
f.write("temporary content")
success, final = store.finalize("upload_001", "final_doc.txt")
print(f"Finalized: {success}")
if success:
print(f"Path: {final}")
print(f"Temp exists: {os.path.exists(temp_path)}")
Expected output:
Finalized: True
Path: /tmp/tus_final/20250115_final_doc.txt
Temp exists: False
Common Mistakes
1. Not Supporting HEAD Requests
Without the HEAD endpoint, clients cannot determine the current offset and cannot resume.
2. Ignoring Offset Mismatch Errors
If the client sends data from the wrong offset, the file becomes corrupted. Always verify offset before writing.
3. No Expiration for Incomplete Uploads
Incomplete uploads accumulate on disk. Implement a TTL for unfinished sessions.
4. Exposing File Paths in Upload URLs
Upload IDs should be opaque. Do not expose internal file paths or directory structures.
5. Not Supporting CORS
Tus clients are often browser-based. The server must include proper CORS headers for tus endpoints.
Practice Questions
1. What HTTP methods does the tus protocol use?
POST (create), PATCH (upload data), HEAD (query offset), DELETE (terminate).
2. What is the Upload-Offset header used for?
It tells the server where to write the incoming data in the file. The client sends it with PATCH requests.
3. How does the client know where to resume?
It sends a HEAD request to the upload URL and reads the Upload-Offset response header.
4. What happens if the server offset does not match the client offset?
The server returns a 409 Conflict. The client must re-query the offset and adjust.
Challenge
Build a full tus server implementation that handles creation, PATCH, HEAD, and DELETE, with temporary storage and automatic cleanup of expired uploads.
FAQ
Mini Project: Tus File Server
# tus_file_server.py
import os
import time
from typing import Optional, Dict
class TusFileServer:
def __init__(self, temp_dir: str, final_dir: str, expiration_hours: int = 24):
self.temp_dir = temp_dir
self.final_dir = final_dir
self.expiration = expiration_hours * 3600
self.uploads: Dict[str, dict] = {}
os.makedirs(temp_dir, exist_ok=True)
os.makedirs(final_dir, exist_ok=True)
def handle_post(self, upload_length: int, metadata: dict = None) -> dict:
upload_id = f"tus_{int(time.time())}_{abs(hash(str(metadata)))}"
path = os.path.join(self.temp_dir, upload_id)
self.uploads[upload_id] = {
"total_size": upload_length,
"offset": 0,
"path": path,
"created": time.time(),
"metadata": metadata or {},
}
with open(path, "wb") as f:
pass
return {"location": f"/files/{upload_id}", "id": upload_id}
def handle_patch(self, upload_id: str, data: bytes,
expected_offset: int) -> Optional[dict]:
upload = self.uploads.get(upload_id)
if not upload:
return None
if expected_offset != upload["offset"]:
return {"error": "offset_mismatch", "current_offset": upload["offset"]}
with open(upload["path"], "ab") as f:
f.write(data)
upload["offset"] += len(data)
if upload["offset"] >= upload["total_size"]:
final_path = os.path.join(
self.final_dir,
f"complete_{upload_id}"
)
os.rename(upload["path"], final_path)
upload["complete"] = True
upload["final_path"] = final_path
return {"offset": upload["offset"]}
def handle_head(self, upload_id: str) -> Optional[dict]:
upload = self.uploads.get(upload_id)
if not upload:
return None
return {"offset": upload["offset"], "total": upload["total_size"]}
def cleanup_expired(self):
now = time.time()
for uid, upload in list(self.uploads.items()):
if now - upload["created"] > self.expiration:
if os.path.exists(upload["path"]):
os.remove(upload["path"])
del self.uploads[uid]
server = TusFileServer("/tmp/tus_temp", "/tmp/tus_final", 24)
result = server.handle_post(500, {"filename": "doc.pdf"})
uid = result["id"]
print(f"Upload created: {uid}")
server.handle_patch(uid, b"x" * 200, 0)
info = server.handle_head(uid)
print(f"After 200 bytes: offset={info['offset']}")
server.handle_patch(uid, b"y" * 300, 200)
info = server.handle_head(uid)
print(f"Complete: offset={info['offset']} (total={info['total']})")
Expected output:
Upload created: tus_1705430000_12345678
After 200 bytes: offset=200
Complete: offset=500 (total=500)
What's Next
You understand resumable uploads with tus. Next, learn tracking upload progress, then explore upload security best practices.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro