Skip to content
Devix Open Source

Guide

Uploading: chunks, retries and your server

A whole file

By default each file is one POST of multipart/form-data — what every server already accepts:

POST /upload
Content-Type: multipart/form-data; boundary=…

  file: the file, under `fieldName`
  …any `data` fields
// Laravel
public function store(Request $request)
{
    $request->validate(['file' => ['required', 'file', 'max:5120']]);
    $path = $request->file('file')->store('uploads');

    return response()->json(['path' => $path]);   // whatever you return reaches onSuccess
}

Whatever the server answers with reaches onSuccess(file, response) as text, and stays on the file as file.response. getResponses() collects them all — which is how you post a form of ids after the files have gone up.

In pieces

createFileUploader(input, { url: '/upload', chunkSize: 5_000_000 });

Each piece is a plain POST — no envelope, no base64, no tus — so the bytes on the wire are the bytes of the file:

Header What it is
X-Upload-Id The same string for every piece of one file, and the same again if it is retried later.
X-Chunk-Index 0-based.
X-Chunk-Count How many there are.
X-Chunk-Offset Where this piece starts in the file.
X-File-Name URI-encoded.
X-File-Size The whole file, in bytes.
X-File-Type The media type.
X-Data-* Each of your data fields, URI-encoded.

The body is the raw slice, as application/octet-stream. The last piece's response is the response — the server answers once, at the end, exactly as it would for a whole file.

Resuming

Before the first piece, the client sends HEAD to the same URL with X-Upload-Id and reads:

X-Uploaded-Bytes: 10485760

and starts from there. A server that does not answer, or answers 0, simply gets the whole file. Turn the probe off with resume: false.

A receiver, in full

Route::match(['head', 'post'], '/upload', function (Request $request) {
    $id = preg_replace('/[^\w.-]/', '', $request->header('X-Upload-Id', ''));
    abort_if($id === '', 400);
    $path = storage_path("app/chunks/{$id}.part");

    if ($request->isMethod('head')) {
        return response('', 200, ['X-Uploaded-Bytes' => is_file($path) ? filesize($path) : 0]);
    }

    // Append this piece where it says it goes, so a repeat is harmless.
    File::ensureDirectoryExists(dirname($path));
    $handle = fopen($path, 'c');
    fseek($handle, (int) $request->header('X-Chunk-Offset', 0));
    fwrite($handle, $request->getContent());
    fclose($handle);

    $last = (int) $request->header('X-Chunk-Index') === (int) $request->header('X-Chunk-Count') - 1;
    if (! $last) {
        return response()->noContent();
    }

    $name = urldecode($request->header('X-File-Name', 'upload'));
    $final = 'uploads/'.Str::random(40).'.'.pathinfo($name, PATHINFO_EXTENSION);
    Storage::put($final, fopen($path, 'r'));
    unlink($path);

    return response()->json(['path' => $final]);
});

Nothing in that is specific to this package: it is a file, an offset, and a write. The browser test suite ships an equivalent in twenty lines of Node, and the tests prove resume works against it.

In production, check the size against X-File-Size before you accept the first piece, cap what one id may write, and sweep old .part files on a schedule.

When something goes wrong

A failure is retried only when retrying could help:

Status Retried? Because
0 (the connection dropped) yes It might come back.
408, 429 yes Too slow, or too fast.
5xx yes The server is having a moment.
4xx (401, 403, 404, 422) no The server has looked at it and said no.

retries is how many extra tries (default 2), each waiting twice as long as the last. After that the row shows a Try again button.

createFileUploader(input, {
  url: '/upload',
  retries: 3,
  onError: (file, message) => toast(`${file.file.name}: ${message}`),
});

Changing a file before it is sent

createFileUploader(input, {
  url: '/upload',
  transform: async (file) => {
    if (!file.type.startsWith('image/')) return file;
    const bitmap = await createImageBitmap(file);
    const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height));
    const canvas = new OffscreenCanvas(bitmap.width * scale, bitmap.height * scale);
    canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height);
    return canvas.convertToBlob({ type: 'image/webp', quality: 0.82 });
  },
});

Half the bandwidth of a phone-camera upload, in twelve lines and no dependency. The widget does not ship an image codec of its own: the browser already has several.

A token that must stay fresh

createFileUploader(input, {
  url: '/upload',
  headers: () => ({ Authorization: `Bearer ${auth.token()}` }),
});

headers and data are read again for every request, so a long upload does not carry the token it started with.

Updated 12 Sep 2026