Skip to content

Resumable Uploads — Complete Guide

DodaTech Updated 2026-06-28 8 min read

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

What is the tus protocol?

An open protocol for resumable file uploads based on HTTP. It uses POST, PATCH, HEAD, and DELETE methods.

Is tus compatible with all browsers?

Yes. Tus uses standard HTTP methods and works with XMLHttpRequest and fetch.

How does the server know the total file size?

The client sends an Upload-Length header during the POST creation request.

Can tus handle concurrent uploads to the same file?

No. Each upload URL represents one file being uploaded from one client.

Is there official tus middleware for popular frameworks?

Yes. Tus has official server implementations for Node.js, Python, Ruby, Go, and Java.

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