Showing upload progress in SvelteKit

Aug 18, 2026 SvelteKit

The file upload tutorial submits with use:enhance, which turns the POST into a fetch. Fetch does not tell you how much of the body has been sent, so you cannot drive a percent. The fetch PUT to S3 has the same problem.

XMLHttpRequest reports that. Its upload.onprogress handler receives a ProgressEvent with loaded and total. Put the listener on upload, not on xhr.onprogress. That second one is download of the response.

Drop use:enhance on the upload form. The form action stays as it is. You still need a Node or serverless adapter; adapter-static has no server to receive the POST.

You will change:

src/routes/upload/+page.svelte

Why fetch cannot do this

use:enhance calls fetch with a FormData body. The Fetch API has no upload progress event. Wrapping a ReadableStream and counting bytes you enqueue only measures how fast your script feeds the stream, and duplex: 'half' request bodies are still uneven across browsers. XMLHttpRequest.upload.onprogress reports what the browser has actually sent. You only change the page. The action still reads FormData the same way.

Step 1: post the form yourself

Take the form from the file upload tutorial and drop use:enhance. Handle submit with deserialize and applyAction. SvelteKit’s form-action docs show that for a custom fetch listener; here the request is an XMLHttpRequest so upload.onprogress is available.

Replace src/routes/upload/+page.svelte:

<script lang="ts">
  import { applyAction, deserialize } from '$app/forms';
  import { invalidateAll } from '$app/navigation';
  import type { ActionData, PageData } from './$types';

  let { data, form }: { data: PageData; form: ActionData } = $props();

  let loaded = $state(0);
  let total = $state<number | null>(null);
  let busy = $state(false);
  let errorMessage = $state('');
  let request: XMLHttpRequest | null = null;

  let percent = $derived(total && total > 0 ? Math.round((loaded / total) * 100) : 0);

  function send(
    method: string,
    url: string,
    body: XMLHttpRequestBodyInit,
    headers: Record<string, string> = {}
  ): Promise<XMLHttpRequest> {
    const req = new XMLHttpRequest();
    request = req;
    req.open(method, url);
    for (const [name, value] of Object.entries(headers)) {
      req.setRequestHeader(name, value);
    }
    loaded = 0;
    total = null;

    return new Promise((resolve, reject) => {
      req.upload.onprogress = (event) => {
        loaded = event.loaded;
        total = event.lengthComputable ? event.total : null;
      };
      req.onload = () => resolve(req);
      req.onerror = () => reject(new Error('Network error'));
      req.onabort = () => reject(new DOMException('Aborted', 'AbortError'));
      req.send(body);
    });
  }

  async function handleSubmit(event: SubmitEvent) {
    event.preventDefault();
    const formEl = event.currentTarget;
    if (!(formEl instanceof HTMLFormElement)) return;

    busy = true;
    errorMessage = '';

    try {
      const body = new FormData(formEl, event.submitter);
      const req = await send('POST', formEl.action, body, {
        'x-sveltekit-action': 'true'
      });
      const result = deserialize(req.responseText);
      if (result.type === 'success') {
        await invalidateAll();
      }
      await applyAction(result);
    } catch (err) {
      if (err instanceof DOMException && err.name === 'AbortError') {
        return;
      }
      errorMessage = err instanceof Error ? err.message : 'Upload failed';
    } finally {
      busy = false;
      request = null;
    }
  }

  function cancel() {
    request?.abort();
  }
</script>

<h1>Upload an image</h1>

<form method="POST" enctype="multipart/form-data" onsubmit={handleSubmit}>
  <label>
    Image
    <input
      type="file"
      name="file"
      accept="image/jpeg,image/png,image/webp"
      required
    />
  </label>
  <button type="submit" disabled={busy}>Upload</button>
  {#if busy}
    <button type="button" onclick={cancel}>Cancel</button>
  {/if}
</form>

{#if busy}
  <p>
    {#if total === null}
      <progress></progress>
      Uploading
    {:else}
      <progress value={loaded} max={total}></progress>
      {percent}%
    {/if}
  </p>
{/if}

{#if errorMessage}
  <p>{errorMessage}</p>
{/if}

{#if form?.message}
  <p>{form.message}</p>
{/if}

{#if form?.success}
  <p>Saved.</p>
{/if}

<ul>
  {#each data.files as name (name)}
    <li>
      <a href="/upload/{name}">
        <img src="/upload/{name}" alt="" width="160" />
      </a>
    </li>
  {/each}
</ul>

If JavaScript is off, the form still POSTs to the action. You will not get a progress bar, because that API only exists in JavaScript.

new FormData(formEl, event.submitter) includes the file input. If you later add named actions, it also includes the button that was clicked.

Do not set Content-Type on this request. The browser sets multipart/form-data with the correct boundary from the FormData. Set the header yourself and the boundary is missing, so the action sees no file.

x-sveltekit-action: true tells SvelteKit to return the serialized action result. Without it, a +server.ts on the same route would steal the POST. Even without one, you can get an HTML page back, which deserialize cannot read.

SvelteKit 3 reports fail() as HTTP 400. SvelteKit 2 still uses 200. Read result.type, not only req.status.

invalidateAll reruns load so the thumbnail list updates. applyAction writes fail() messages into the form prop, so the template can keep form.message.

Step 2: the progress bar

A <progress> element without a value is indeterminate. Use that until the first event with lengthComputable, then set value to loaded and max to total. Those are the bytes the browser reports.

percent is a $derived so the label stays in sync with loaded / total.

On a fast local disk the bar may jump to 100% immediately. Throttle the request in the browser’s Network tab (Slow 3G) and pick a few-megabyte file if you want to see it move. BODY_SIZE_LIMIT still applies once you deploy, the same as in the file upload tutorial.

req.upload.onload fires when the body has left the browser. The action may still be writing the file. Leave busy true until req.onload, which is when the response arrives.

Step 3: cancel

XMLHttpRequest.abort() stops the transfer. The promise rejects with AbortError; skip the error message then. A fetch AbortController does not apply, because this upload is not a fetch.

The Cancel button is type="button" so it does not submit the form.

Step 4: the same PUT to S3

The presigned URL post uses fetch twice. The POST to /upload/presign is a few hundred bytes of JSON, so leave that as fetch. The PUT to S3 is the file. Copy send into that page and swap the PUT:

const put = await send('PUT', url, file, {
  'content-type': contentType
});

if (put.status < 200 || put.status >= 300) {
  message = 'S3 rejected the upload';
  return;
}

Do not listen for progress on the presign request. It finishes too fast to be useful, and those are not the bytes you care about.

CORS is the same as with fetch. The bucket already allows PUT and Content-Type.

Anyone who can load /upload can still POST to it. If this is a user avatar, check locals.user the same way HttpOnly cookie auth does.

Try it

  1. Keep the action from the file upload tutorial. Open /upload.
  2. In DevTools, throttle the network. Pick a JPEG of a couple of megabytes.
  3. Submit. The bar should move, then the image should appear under the form.
  4. Submit again and hit Cancel. The list should stay as it was.
  5. If you followed the S3 post, put a large file through the PUT path. The network tab should show progress on the amazonaws.com request, not on /upload/presign.

If the bar stays at 0% and then the page updates, you are still using fetch or use:enhance. If form never gets the error message, you are not calling applyAction. If deserialize throws, the response was HTML: set x-sveltekit-action.

Things that trip people up

Listening to xhr.onprogress instead of xhr.upload.onprogress. The first is the response body coming back, which is tiny for a success payload.

Setting Content-Type: multipart/form-data by hand. The boundary is then missing.

Treating 100% as success. The bar measures the upload. Wait for the action’s response before you show “Saved.”

Using fetch with a ReadableStream body and calling that progress. That counts enqueue, not the network.

Posting to a +server.ts on the same path as the page without x-sveltekit-action: true. SvelteKit sends the POST to the endpoint instead of the action.

Expecting progress from use:enhance. That helper uses fetch.

What to add next

Check locals.user in the action, as in HttpOnly cookie auth and serving uploaded files behind auth.

Verify the bytes before you keep the file. file.type is still only what the browser claimed.

If the file is larger than your function’s body limit, use the S3 PUT path above. A single PUT still has a 5 GB cap. Bigger files need multipart upload, where you sum progress across parts.