Skip to content

Uploading Large Files in PHP Without Timeouts

Somewhere past a few hundred megabytes, a normal PHP upload stops being a configuration problem and becomes a design problem. One enormous POST is fragile no matter how high you set the limits. The fix is to stop sending one request.

Why big uploads fail

A single-request upload has to survive every one of these at once:

You can raise the first two. You cannot make one long request reliable.

Chunked uploads

Split the file in the browser, send each piece as its own small request, and reassemble server-side. Every individual request is well under any limit, finishes quickly, and can be retried in isolation.

Client side

File inherits slice() from Blob, so slicing costs nothing — no copy is made until the slice is read.

async function uploadInChunks(file, url, chunkSize = 5 * 1024 * 1024) {
  const total = Math.ceil(file.size / chunkSize);
  const id    = crypto.randomUUID();          // identifies this upload

  for (let i = 0; i < total; i++) {
    const start = i * chunkSize;
    const end   = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end);

    const body = new FormData();
    body.append('chunk', chunk);
    body.append('uploadId', id);
    body.append('index', i);
    body.append('total', total);
    body.append('name', file.name);

    let attempt = 0;
    while (true) {
      try {
        const res = await fetch(url, { method: 'POST', body });
        if (!res.ok) throw new Error('HTTP ' + res.status);
        break;                                 // chunk accepted
      } catch (err) {
        if (++attempt >= 3) throw err;         // give up after 3 tries
        await new Promise(r => setTimeout(r, 1000 * attempt));
      }
    }

    onProgress((end / file.size) * 100);
  }
}

Because each chunk is a separate request, a failure retries 5 MB rather than 2 GB — and progress is a natural by-product of the loop rather than something you have to instrument.

Server side

<?php
declare(strict_types=1);

$tmpDir = '/var/www/uploads/parts';           // outside the web root

$id    = preg_replace('/[^a-f0-9-]/i', '', $_POST['uploadId'] ?? '');
$index = (int) ($_POST['index'] ?? -1);
$total = (int) ($_POST['total'] ?? 0);

if ($id === '' || $index < 0 || $total <= 0 || $index >= $total) {
    http_response_code(400);
    exit('Bad chunk metadata.');
}
if (($_FILES['chunk']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
    http_response_code(400);
    exit('Chunk did not arrive.');
}

$dir = $tmpDir . '/' . $id;                   // $id is sanitised above
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
    http_response_code(500);
    exit('Cannot create staging directory.');
}

move_uploaded_file($_FILES['chunk']['tmp_name'], sprintf('%s/%06d.part', $dir, $index));

// all parts present? then assemble
if (count(glob($dir . '/*.part')) === $total) {
    $final = fopen('/var/www/uploads/' . bin2hex(random_bytes(16)) . '.bin', 'wb');
    for ($i = 0; $i < $total; $i++) {
        $part = fopen(sprintf('%s/%06d.part', $dir, $i), 'rb');
        stream_copy_to_stream($part, $final);  // constant memory
        fclose($part);
    }
    fclose($final);
    array_map('unlink', glob($dir . '/*.part'));
    rmdir($dir);
    echo json_encode(['status' => 'complete']);
    exit;
}

echo json_encode(['status' => 'chunk-received', 'index' => $index]);

Points worth keeping:

Concurrency and cleanup

Two details bite in production. If you send chunks in parallel for speed, the "is it complete?" check can fire twice at once — guard assembly with a lock file or an atomic mkdir. And abandoned uploads leave partial directories forever, so a cron job should remove staging directories older than a day:

find /var/www/uploads/parts -mindepth 1 -maxdepth 1 -type d -mtime +1 -exec rm -rf {} +

Making uploads resumable

Once chunks are stored individually, resuming is a small addition: before uploading, ask the server which parts it already holds and skip those.

// GET /upload-status.php?uploadId=...
$have = array_map(
    fn($p) => (int) basename($p, '.part'),
    glob($tmpDir . '/' . $id . '/*.part')
);
echo json_encode(['received' => $have]);
const { received } = await (await fetch(`/upload-status.php?uploadId=${id}`)).json();
const have = new Set(received);
for (let i = 0; i < total; i++) {
  if (have.has(i)) { onProgress(((i + 1) / total) * 100); continue; }
  // ... upload chunk i
}

Keep the upload id in localStorage keyed by file name and size, and a user who closes the tab mid-transfer can pick up where they left off rather than starting over.

Settings that still matter

Chunking removes the pressure on the big limits, but a few settings still apply per chunk:

upload_max_filesize = 8M     ; just larger than one chunk
post_max_size = 10M          ; chunk plus metadata
max_execution_time = 60      ; assembly of a very large file can take a while
memory_limit = 128M          ; unchanged - streaming does not need more

Note that assembling a 20 GB file from 4,000 parts is itself slow. If that is your workload, write chunks directly into their final offsets with fseek and fwrite instead of concatenating at the end, or hand assembly to a background worker.

The built-in option

PHP File Uploader does this transparently: files are transferred in parts, streamed to disk, and reassembled server-side, with a progress bar and cancellation for the user. Nothing special is required in your page — the same few properties as a small upload:

<?php
$uploader = new PhpUploader();
$uploader->MultipleFilesUpload = true;
$uploader->InsertText = "Upload";
$uploader->AllowedFileExtensions = "zip,mp4,iso";
$uploader->SaveDirectory = "uploads";
$uploader->Render();
?>

The large-file demo runs it against real files, and the deployment guide covers the temporary-directory setup that large transfers need.