Skip to content

Building an AJAX Upload with a Progress Bar

A form post gives you no progress, no cancel button and a blank page while the browser works. Moving the transfer into JavaScript fixes all three — and the API that reports progress is not the modern one you would reach for first.

fetch() cannot report upload progress

This surprises people, because fetch() replaced XMLHttpRequest for essentially everything else. But fetch() exposes a readable stream for the response and nothing for the request body. You can tell when the upload finished; you cannot tell how far along it is.

Request streaming exists in newer Chromium (duplex: 'half') but requires HTTP/2, is not available in every browser, and still does not give you a byte counter directly. For an upload progress bar today, XMLHttpRequest remains the correct tool — it is not legacy code, it is the only API that reports this.

The client

<input type="file" id="file" multiple>
<button id="send">Upload</button>
<button id="cancel" hidden>Cancel</button>

<div class="bar"><div id="fill"></div></div>
<p id="status"></p>
const fileInput = document.getElementById('file');
const fill      = document.getElementById('fill');
const status    = document.getElementById('status');
let xhr = null;

document.getElementById('send').addEventListener('click', () => {
  if (!fileInput.files.length) return;

  const body = new FormData();
  for (const f of fileInput.files) body.append('files[]', f);

  xhr = new XMLHttpRequest();
  xhr.open('POST', '/upload.php');

  // progress lives on xhr.upload, NOT on xhr itself -
  // xhr.onprogress reports the download of the response
  xhr.upload.addEventListener('progress', (e) => {
    if (!e.lengthComputable) return;
    const pct = (e.loaded / e.total) * 100;
    fill.style.width = pct.toFixed(1) + '%';
    status.textContent =
      `${fmt(e.loaded)} of ${fmt(e.total)} - ${pct.toFixed(0)}%`;
  });

  xhr.addEventListener('load', () => {
    status.textContent = xhr.status === 200
      ? 'Upload complete.'
      : 'Server returned ' + xhr.status;
  });
  xhr.addEventListener('error', () => { status.textContent = 'Network error.'; });
  xhr.addEventListener('abort', () => { status.textContent = 'Cancelled.'; });

  xhr.send(body);                  // do NOT set Content-Type yourself
});

document.getElementById('cancel').addEventListener('click', () => xhr && xhr.abort());

const fmt = (b) => b > 1048576
  ? (b / 1048576).toFixed(1) + ' MB'
  : (b / 1024).toFixed(0) + ' KB';

Three things there are easy to get wrong:

Speed and time remaining

Users judge an upload by whether it looks like it is moving. Rate and ETA cost a few lines:

let started = Date.now();

xhr.upload.addEventListener('progress', (e) => {
  if (!e.lengthComputable) return;
  const secs = (Date.now() - started) / 1000;
  const rate = e.loaded / Math.max(secs, 0.001);        // bytes/sec
  const left = (e.total - e.loaded) / Math.max(rate, 1); // seconds
  status.textContent =
    `${(rate / 1048576).toFixed(1)} MB/s - about ${Math.ceil(left)}s remaining`;
});

Smooth the rate over the last few events rather than using the instantaneous value, or the number jitters distractingly.

The PHP endpoint

Server-side there is nothing AJAX-specific: it is an ordinary multipart POST. The only difference is that you answer with JSON instead of a redirect.

<?php
declare(strict_types=1);
header('Content-Type: application/json');

$targetDir = __DIR__ . '/../uploads';
$allowed   = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'application/pdf' => 'pdf'];
$results   = [];

if (empty($_FILES['files']) && ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) {
    http_response_code(413);
    exit(json_encode(['error' => 'Upload exceeded post_max_size.']));
}

foreach ($_FILES['files']['name'] as $i => $name) {
    $err = $_FILES['files']['error'][$i];
    if ($err !== UPLOAD_ERR_OK) {
        $results[] = ['name' => $name, 'ok' => false, 'error' => upload_error_message($err)];
        continue;
    }

    $tmp  = $_FILES['files']['tmp_name'][$i];
    $mime = (new finfo(FILEINFO_MIME_TYPE))->file($tmp);

    if (!isset($allowed[$mime]) || !is_uploaded_file($tmp)) {
        $results[] = ['name' => $name, 'ok' => false, 'error' => 'File type not permitted.'];
        continue;
    }

    $stored = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
    $results[] = move_uploaded_file($tmp, $targetDir . '/' . $stored)
        ? ['name' => $name, 'ok' => true, 'stored' => $stored]
        : ['name' => $name, 'ok' => false, 'error' => 'Could not save.'];
}

echo json_encode(['files' => $results]);

Return a per-file result rather than a single boolean: with multiple files, "it failed" is not an answer the user can act on. The validation here is the short version — the security checklist covers what a production handler needs.

Per-file progress for a batch

The code above sends every file in one request, so the bar tracks the batch as a whole. Users generally expect a row per file, each with its own bar. That means one request per file:

async function uploadEach(files, url) {
  for (const file of files) {
    const row = addRow(file.name);              // your own UI helper
    await new Promise((resolve, reject) => {
      const body = new FormData();
      body.append('files[]', file);

      const xhr = new XMLHttpRequest();
      xhr.open('POST', url);
      xhr.upload.onprogress = (e) => {
        if (e.lengthComputable) row.setPercent((e.loaded / e.total) * 100);
      };
      xhr.onload  = () => { row.done(); resolve(); };
      xhr.onerror = () => { row.failed(); reject(new Error('network')); };
      xhr.send(body);
    });
  }
}

Sequential keeps the ordering predictable and the server load sane; a small concurrency pool (two or three at a time) is faster on high-latency links. Either way, once you are managing rows, retries, cancellation per file and an overall total, you are building a component — which is the point at which using one starts to pay.

Session-based progress is obsolete. Older tutorials poll a second endpoint reading session.upload_progress or the APC uploadprogress extension. That was a workaround for browsers without xhr.upload. Every browser in use today reports progress client-side; the polling approach only adds moving parts.

The component version

PHP File Uploader is this pattern, finished: per-file progress with speed and time remaining, cancellation, client-side validation before transfer, queueing, and chunked transfer so large files are not one fragile request.

<?php
$uploader = new PhpUploader();
$uploader->MultipleFilesUpload = true;
$uploader->MaxSizeKB = 102400;
$uploader->AllowedFileExtensions = "jpg,png,pdf";
$uploader->Render();
?>

Try the progress-bar demo, the AJAX multiple-file demo which posts the file list to a handler without reloading, or the custom UI demo if you want to supply your own progress markup.