---
title: 'Making large uploads resumable with TurboPush'
canonical: 'https://www.dealmobile.co.uk/writing/making-large-uploads-resumable'
url: 'https://www.dealmobile.co.uk/writing/making-large-uploads-resumable'
type: Post
published: '2026-10-19'
summary: '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.'
---

# Making large uploads resumable with TurboPush

Published 2026-10-19

![A row of twelve chunks: the first eight are marked as already held by the server, the last four are highlighted as the ones sent now, with the client asking the server for its status.](https://www.dealmobile.co.uk/sites/default/files/styles/almanac_shot_md/public/content/images/posts/making-large-uploads-resumable/hero.png.webp?itok=dPV4BdFw)

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](https://www.dealmobile.co.uk/projects/turbopush.md) 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:

```ts
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:

```ts
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:

```php
$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](https://tus.io) 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](https://turbopush.dealmobile.co.uk) can simulate failing chunks, so you can watch the retries happen.

- **Projects:** [TurboPush](https://www.dealmobile.co.uk/projects/turbopush.md)
- **Tags:** [TypeScript](https://www.dealmobile.co.uk/writing/tag/typescript.md), [PHP](https://www.dealmobile.co.uk/writing/tag/php.md), [Uploads](https://www.dealmobile.co.uk/writing/tag/uploads.md), [Security](https://www.dealmobile.co.uk/writing/tag/security.md), [TurboPush](https://www.dealmobile.co.uk/writing/tag/turbopush.md)

---

Index of this site: [llms.txt](https://www.dealmobile.co.uk/llms.txt)
