Skip to content
Carl Victor Fontanos
Carl Victor Fontanos

Carl Victor Fontanos

Software Engineer

I build web applications and share what I learn along the way.

© 2026

A Real Upload Progress Bar with XMLHttpRequest and PHP

C
Carlo Fontanos
· 2 min read

Here's a fun modern-web irony: fetch() - the modern API - still can't give you upload progress in a broadly usable way (its streaming-upload option remains limited and HTTP/2-gated), while XMLHttpRequest has shipped perfect upload progress events since 2010. For upload UX, the old API remains the right tool, and there's no shame in it.

The client

function uploadWithProgress(file, onProgress) {
    return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        const body = new FormData();
        body.append('file', file);

        // The magic property: xhr.UPLOAD.onprogress, not xhr.onprogress
        xhr.upload.addEventListener('progress', (e) => {
            if (e.lengthComputable) onProgress(e.loaded / e.total);
        });

        xhr.addEventListener('load', () =>
            xhr.status < 300 ? resolve(JSON.parse(xhr.responseText))
                             : reject(new Error('HTTP ' + xhr.status)));
        xhr.addEventListener('error', () => reject(new Error('Network error')));

        xhr.open('POST', '/upload.php');
        xhr.send(body);
    });
}

// Usage with a <progress> element - semantic, accessible, styleable
const bar = document.querySelector('progress');
await uploadWithProgress(file, p => { bar.value = p * 100; });

The classic mistake is attaching progress to xhr instead of xhr.upload - that measures the download of the response (a few bytes of JSON, instantly done) and your bar jumps 0→100 at the end. The Promise wrapper keeps the calling code modern async/await despite the veteran API underneath.

The PHP receiver

$file = $_FILES['file'] ?? null;

if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
    http_response_code(422);
    exit(json_encode(['error' => 'Upload failed (code ' . ($file['error'] ?? '?') . ')']));
}

// Validate CONTENT, not the filename - see the finfo tutorial
$name = bin2hex(random_bytes(12)) . '.' . validatedExtension($file['tmp_name']);
move_uploaded_file($file['tmp_name'], UPLOAD_DIR . $name);

echo json_encode(['ok' => true, 'name' => $name]);

All the security rules from the MIME-detection tutorial apply verbatim - progress bars don't make bytes trustworthy.

The php.ini trap that gets everyone

Your beautiful progress bar reaches 100%... and the server responds with an empty $_FILES and error code 1. Four settings gate uploads, and all must be big enough:

upload_max_filesize = 100M
post_max_size       = 110M   ; must EXCEED upload_max_filesize (form overhead)
memory_limit        = 256M
max_execution_time  = 300    ; slow connections need time

The cruelest case: when POST data exceeds post_max_size, PHP hands your script an empty $_POST and $_FILES with no exception - the request just looks mysteriously blank. Check $_SERVER['CONTENT_LENGTH'] against these limits early and return a real error message; your future self will thank you during the 2 a.m. debugging session.

Add drag-and-drop or clipboard paste as the input, per-file bars from the same function, and you've matched the upload UX of any SaaS product - with one XHR, one PHP file, and four ini lines.

C
Written by Carlo Fontanos

Full-stack web developer sharing practical tutorials and building tools that ship.

Got something on your mind?

My inbox is open - no forms disappearing into the void here.

  • Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
  • Hire me for a project Have something custom in mind? Let's talk scope and timelines.
  • Product support Bought something here? I'll help you get it running.

I usually reply within 1-2 business days.

Message sent!

Your details are only used to reply to you.

Keep reading