Tom Gould

Writing

Making large uploads resumable with TurboPush

How TurboPush splits big files into chunks, retries the ones that fail, carries on after a page reload, and keeps the PHP side safe when chunks arrive twice or all at once.

Uploading a multi-gigabyte file in one POST is fragile. If the connection drops at 90%, the user starts again. PHP’s upload_max_filesize and post_max_size get in the way long before that. TurboPush is my answer: a TypeScript client that sends files in chunks, and a PHP endpoint that puts them back together. Versions 2.0 and 2.1 came out on 18 October 2026, and this is how their main parts work.

Chunks

The client slices each File with Blob.slice() into chunks, 1MB by default, and posts each one as an ordinary multipart request. Every chunk carries enough to place it: the upload ID, file name, file size, total number of chunks and its own index.

POST /upload.php
fileId=4f2c...  fileName=video.mp4  fileSize=2147483648
totalChunks=2048  chunkIndex=17  file=<1MB of bytes>

Up to three chunk requests are in flight at once, and that limit applies across all files, not to each file. When every chunk has arrived, the client sends a JSON finalize action, and the server assembles the file.

Retries that don’t stampede

Network errors, timeouts and HTTP 408, 429 and 5xx responses are treated as temporary, so the chunk is retried. Other 4xx responses, such as a file type the server refuses, fail the file at once, because retrying won’t help.

The delay doubles with each attempt, up to a ceiling, with a random part so that many clients retrying after the same outage don’t all come back at the same moment:

private retryDelayFor(attempt: number): number {
    const base = Math.min(this.config.maxRetryDelay, this.config.retryDelay * Math.pow(2, attempt - 1));
    return Math.round(base / 2 + Math.random() * (base / 2));
}

Pausing aborts the chunks in flight. They’re sent again on resume, and they don’t count as retries.

Surviving a reload

With resumable: true, the client stores each upload’s ID in localStorage, under a key made from the endpoint and the file’s name, size and last-modified time:

private storageKey(file: File): string {
    const prefix = this.config.protocol === 'tus' ? 'turbopush:tus:' : 'turbopush:';
    return `${prefix}${this.config.endpoint}:${file.name}:${file.size}:${file.lastModified || 0}`;
}

When the same file is added again, after a reload or in another tab, the client finds the saved ID and sends a status action. The server replies with the chunks it already holds, and the client sends only the rest. If the server has nothing usable, for example because the chunk size has changed, it starts again. The saved ID is removed when the upload completes or is cancelled. IDs are random, so one user can’t resume into another user’s upload.

A server that expects the worst

Chunks can arrive out of order, in parallel, twice, or from someone who isn’t playing fair, so the PHP endpoint is the strict half.

Each upload gets its own folder outside the web root, and each chunk or finalize request for that upload takes a lock on it with flock(). Inside the lock, a chunk is first written to a unique temporary name and then renamed into place. A retried or duplicated chunk can never leave a half-written file behind:

$chunkPath = $fileTempDir . $this->chunkFileName($chunkIndex);
$partPath = $chunkPath . '.' . bin2hex(random_bytes(6)) . '.part';

// ...move the upload to $partPath, check its size...

$previousSize = is_file($chunkPath) ? $this->fileSize($chunkPath) : 0;
$receivedBytes = (int) $meta['receivedBytes'] - $previousSize + $chunkSize;
if ($receivedBytes > $fileSize) {
    throw new TurboPushException('Upload exceeds the declared file size', 400);
}
rename($partPath, $chunkPath);

The running total stops a client storing more than it declared. The first chunk’s metadata is recorded, and later chunks that disagree with it get a 409. Finalising holds the same lock, checks that every chunk is there and lists any missing ones in the error. Then it joins the chunks into a temporary file in the destination directory and renames it into place. Repeating a finalize after success returns the same answer, so a client that lost the response can safely ask again.

The endpoint also cleans file names, blocks extensions a web server could execute or a browser could run scripts in, can check the MIME type found in the file’s contents, and never puts server paths in its errors.

Speaking tus too

tus is the open protocol for resumable uploads, with mature servers in many languages. Version 2.1 lets the client speak it as well. With protocol: 'tus' it creates uploads with POST, sends chunks with PATCH, and after any failure asks for the offset with HEAD and carries on from there, even in the middle of a chunk. If the server supports concatenation, a file’s chunks go up in parallel as partial uploads that are joined at the end.

Testing it

The native protocol is written down in PROTOCOL.md, and a conformance suite checks any server against it. The PHP tests include concurrency tests that start real parallel PHP processes against the same upload. Interop tests run the client against tusd and tus-php in Docker. tus-php announces concatenation but doesn’t follow the spec for the final request, so the README says to turn parallel uploads off with that server.

The demo can simulate failing chunks, so you can watch the retries happen.