Skip to content

AdvancedFile Upload

File Upload

Upload files with FormData and real-time progress tracking via XHR.

9 min readboth modes

Overview

Courier supports file uploads using FormData in both direct and proxy modes. When you provide an onUploadProgress callback in a browser, Courier sends the request with XMLHttpRequest, whose upload events fetch lacks, for real network-level progress of the browser's own upload. On a server — a direct call from Node, Deno or Bun — the body is streamed to fetch without being read into memory first: a FormData's files are read from disk as they are sent. Each chunk is reported once fetch has taken it, no event follows a cancel or a timeout, and a 307 or 308 redirect is followed with the body, as it is without progress. A ReadableStream body has no size to report against, so its totalBytes stays 0 until the last event, which reports 100% of what was sent.

Basic file upload

Create a FormData object with your files, pass it as variables, and use onUploadProgress to track progress:

components/avatar-upload.tsx
"use client"

import { useState } from "react"
import { caller } from "@/lib/caller"

export function AvatarUpload() {
  const [progress, setProgress] = useState(0)
  const [uploading, setUploading] = useState(false)

  async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0]
    if (!file) return

    const formData = new FormData()
    formData.append("avatar", file)

    setUploading(true)
    setProgress(0)

    try {
      await caller.users
        .with({
          onUploadProgress: ({ percent }) => {
            setProgress(Math.round(percent * 100))
          },
        })
        .uploadAvatar(formData)
    } finally {
      setUploading(false)
    }
  }

  return (
    <div>
      <input type="file" accept="image/*" onChange={handleUpload} />
      {uploading && <progress value={progress} max={100} />}
    </div>
  )
}

Endpoint definition

Define upload endpoints using courier and defineGroup:

api/users.ts
import { courier, defineGroup } from "@noverax/courier/define"

const { endpoint } = courier()

type User = { id: number; name: string; avatarUrl: string }

const users = defineGroup({
  prefix: "users",
  endpoints: {
    uploadAvatar: endpoint.post("avatar").variables<FormData>().response<User>(),
    uploadDocument: endpoint.post("documents").variables<FormData>().response<{ url: string }>(),
  },
})

Raw binary bodies

An upstream that takes the bytes themselves, rather than a multipart form, is called with the Blob, File or typed array as the variables. In direct mode it is sent as the body, with the Blob's own type as the content type:

ts
const putObject = endpoint.put("objects/raw").variables<Blob>()

await caller.storage.putObject(new Blob([bytes], { type: "image/png" }))

The proxy carries variables as JSON or FormData only, and refuses a raw body with ConfigurationError("PROXY_BODY_NOT_SUPPORTED"). From a browser, send the file in a FormData and let the endpoint's closure hand its bytes to the upstream:

ts
const putObject = endpoint
  .variables<FormData>()
  .via((http, form) => http.put("objects/raw", {}, { body: form.get("file") ?? undefined }))

Progress tracking

The onUploadProgress callback receives an UploadProgress object with three fields:

typescript
type UploadProgress = {
  // 0 to 1 — multiply by 100 for percentage
  percent: number

  // Bytes uploaded so far
  transferredBytes: number

  // Total file size in bytes
  totalBytes: number
}

// Usage:
onUploadProgress: (progress) => {
  console.log(`${Math.round(progress.percent * 100)}%`)
  console.log(`${progress.transferredBytes} / ${progress.totalBytes} bytes`)
}

lengthComputable

Progress events only fire when the browser knows the total size. For FormData uploads, this is always the case. The percent field ranges from 0 to 1.

A bar that moves smoothly

A browser reports an upload's progress every 100 ms or so — on a slow link a step of several percent each — and a server reports in bursts, as fetch takes the body. smoothProgress wraps the callback and moves the bar every 30 ms toward what the upload last reported, closing two thirds of the gap every 120 ms. It never runs ahead of the upload, never goes back, and comes to rest where the upload is, 100% included. Give each upload a wrapper of its own:

components/avatar-upload.tsx
"use client"

import { smoothProgress } from "@noverax/courier"
import { caller } from "@/lib/caller"

export async function uploadAvatar(form: FormData, setProgress: (percent: number) => void) {
  await caller.users
    .with({ onUploadProgress: smoothProgress(({ percent }) => setProgress(percent * 100)) })
    .uploadAvatar(form)
}

Send a file below to see both at once. Your browser uploads it to this site, which reads it at the pace you pick, as a slow link would. The amber bar moves as the browser reports progress; the blue one takes the same reports through smoothProgress.

File size
Upload speed

A recorded run · 8 MB at 4 MB/s — send your own

onUploadProgress

as the browser reports it

100.0%

reports
15
biggest jump
15%
longest wait
105 ms

smoothProgress(…)

the reports, smoothed

100.0%

moves
60
biggest jump
4%
longest wait
32 ms
  • browser reports
  • smoothProgress
  • the site answers

The file is zeros your browser makes. This site reads it at the pace you pick, as a slow link would, then drops it.

Progress counts the bytes handed to the network, as the browser or the runtime reports them. They reach the server a moment later — the platform buffers them on the way — so 100% comes a little before the answer does, and an upload to a slow server can rest at 100% while the server reads the last of it. Show the wait after 100% as such — "Processing…" — until the call resolves.

Progress in proxy mode

In proxy mode an upload travels twice: from the browser to the proxy route, then from the proxy to your backend. onUploadProgress measures the first hop only. The proxy reads the whole upload before it forwards anything, since it parses the form to hand the endpoint its fields, and then sends it on. So the progress reaches 100% when the proxy has the file, and the call resolves once the backend has received it and answered: behind a slow backend, a 25 MB upload can sit at 100% for several seconds.

While it reads, the proxy holds the upload in memory, up to maxUploadBytes (10 MB unless the handler sets it), and briefly a second copy as it parses the parts. Size that limit for your server's memory, and upload straight to storage — a pre-signed URL, or a direct call — when files are large.

Multi-file upload

Upload multiple files in a single request by appending them to the same FormData. You can also include metadata fields alongside the files:

components/document-upload.tsx
"use client"

import { useState } from "react"
import { caller } from "@/lib/caller"

export function DocumentUpload() {
  const [progress, setProgress] = useState(0)

  async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
    const files = e.target.files
    if (!files?.length) return

    const formData = new FormData()

    // Append multiple files under the same key or different keys
    for (const file of files) {
      formData.append("documents", file)
    }

    // Add metadata alongside files
    formData.append("folder", "invoices")
    formData.append("tags", "2024,finance")

    await caller.files
      .with({
        onUploadProgress: ({ percent }) => {
          setProgress(Math.round(percent * 100))
        },
      })
      .upload(formData)
  }

  return (
    <div>
      <input type="file" multiple onChange={handleUpload} />
      <p>{progress}% uploaded</p>
    </div>
  )
}

FormData composition proxy mode

When FormData is sent through the proxy, Courier names the endpoint in the URL and writes the call's options as one field before yours. The server handler takes that field off before forwarding to your endpoint. In direct mode, FormData is sent as-is:

typescript
// When sending FormData through the proxy, Courier composes it like this:

// Your FormData:
formData.append("avatar", file)
formData.append("name", "profile-pic")

// What gets sent:
// POST /api/request?_s=api&_g=users&_m=uploadAvatar
// _options = '{"timeout":30000}' <- the call's options (JSON string)
// avatar   = File                <- your data
// name     = "profile-pic"       <- your data

// The server handler reads the first _options field as the options, and
// passes a FormData with only your data to the endpoint

The options field always comes first, so a field of your own may share a name with it, or take any other name. A form that sends _method=PUT, as Laravel and Rails forms do, reaches your backend with that field intact.

Because the endpoint is in the URL, the proxy knows which call an upload is for as soon as it arrives. Studio shows the call while the upload is still on its way, and an upload over maxUploadBytes ends there with its 413 rather than never appearing.

Binary data outside FormData proxy mode

Variables other than a FormData travel to the proxy as JSON, which has no binary type: a Blob, File, ArrayBuffer, typed array or ReadableStream would arrive as {}. A proxy call whose variables hold one anywhere is therefore refused before it is sent, with a ConfigurationError whose code is PROXY_VARIABLE_NOT_SERIALIZABLE and whose message names the value. Send the file inside a FormData; a direct call from the server carries any body as it is.

typescript
// Refused in proxy mode: the Blob would reach the server as {}
await caller.files.upload({ body: blob })
// ConfigurationError: [courier] variables.body is binary data, ...

// Reaches the server intact
const formData = new FormData()
formData.append("file", blob, "report.pdf")
await caller.files.upload(formData)

Empty file handling

An optional <input type="file"> left blank still submits a file: one with no name and no bytes. Courier leaves that placeholder out of the form it sends, so the backend does not receive an upload nobody chose. A file that has a name is sent even when it is empty — a .gitkeep or an empty log is a real file. Your own FormData is not edited; the placeholder is dropped from the copy that is sent.

typescript
const formData = new FormData(form)
// avatar    = File "photo.png", 120 KB  -> sent
// thumbnail = File "", 0 bytes          -> left out: the input was blank
// notes     = File "notes.txt", 0 bytes -> sent: a file somebody chose

Why XHR?

Courier uses XMLHttpRequest instead of fetch for uploads because the Fetch API does not support upload progress tracking. The switch is automatic and transparent:

typescript
// Why XHR instead of fetch?
//
// The Fetch API does not support upload progress tracking.
// ReadableStream + duplex: "half" only works on HTTP/2 and has
// limited browser support.
//
// XMLHttpRequest provides real network-level progress via the
// upload.onprogress event, which works on both HTTP/1.1 and HTTP/2.
//
// Courier automatically switches from fetch to XHR when
// onUploadProgress is provided. No configuration needed.

// Standard request — uses fetch
await caller.users.getAll()

// Upload request — automatically uses XHR
await caller.users
  .with({
    onUploadProgress: (p) => console.log(p.percent),
  })
  .uploadAvatar(formData)

HTTP/1.1 and HTTP/2

Unlike ReadableStream with duplex: "half", the XHR approach works on both HTTP/1.1 and HTTP/2 with full browser support.

Limitations

When onUploadProgress is set, certain fetch-specific options are not available because the transport switches to XHR:

typescript
// When using onUploadProgress (XHR transport), these options are ignored:

await caller.files
  .with({
    onUploadProgress: ({ percent }) => setProgress(percent),
  
    // Ignored — XHR does not support the fetch cache API
    cache: "no-store",

    // Ignored — Next.js-specific fetch option, not available in XHR
    next: { revalidate: 0 },
  })
  .upload(formData)

// Also ignored in a browser: mode, redirect, keepalive, priority, integrity.
// In development, Courier logs a warning naming each ignored option.
// onDownloadProgress still works, but over XHR it reports the answer only
// once the whole of it has arrived.

A ReadableStream body is read into memory before it is sent in a browser: XMLHttpRequest cannot send a stream, and its progress then covers the bytes that were read. Without onUploadProgress it is streamed as it is, with duplex: "half" — which browsers allow only over HTTP/2 or later.

Development warnings

In development mode, Courier logs a console warning for each ignored option so you can identify unintended usage early. In production, ignored options are silently skipped.

Uploads in Studio

With Studio attached, a call made with onUploadProgress shows its progress in the Timeline while it uploads, and its request detail says how much of the body has been sent. In proxy mode the browser names the call's Studio request id in an X-Courier-Rid header, and the proxy shows the call under that id, so the progress and the proxy's own events land on one row. The proxy announces the call as soon as it arrives, so an upload it refuses — a body over maxUploadBytes, say — appears as a failed request with its status.

Test uploads against a mock

A mock server takes uploads as your backend does, and can take them as a slow or strict one would, so the app's progress bar and its errors can be seen without the backend. Set its upload conditions in Studio's Network tab while it runs, or declare them with the mock:

ts
// courier.mocks.ts
import { courier, defineApi, defineGroup } from "@noverax/courier/define"
import { defineMocks, defineMockServer } from "@noverax/courier-mock"

const { endpoint } = courier()
type User = { id: number; name: string; avatarUrl: string }
const api = defineApi({
  baseURL: "https://api.example.com",
  groups: {
    users: defineGroup({
      prefix: "users",
      endpoints: {
        uploadAvatar: endpoint.post("avatar").variables<FormData>().response<User>(),
        uploadDocument: endpoint.post("documents").variables<FormData>().response<{ url: string }>(),
      },
    }),
  },
})

export default defineMocks({
  api: defineMockServer({
    source: api,
    network: {
      // Every upload arrives at 1.5 Mbps; none over 10 MB is taken
      uploads: { bandwidth: 1500, maxBytes: 10 * 1024 * 1024 },
      routeOverrides: [
        {
          routeId: "users:uploadAvatar",
          enabled: true,
          uploads: { maxBytes: 2 * 1024 * 1024, accept: ["image/png", "image/jpeg"] },
        },
      ],
    },
  }),
})
To seeSetThe app gets
Progress on a slow linkuploads.bandwidth, or Upload speedits progress moving at that speed, past the first part of the file the system takes at once
A file too largeuploads.maxBytes, or Size limit413, { "error": "The upload is 3.2 MB, over the 2 MB the mock takes" }, before the file is sent whole
A type the server does not takeuploads.accept, or File types415, { "error": "photo.heic (image/heic) is not a type the mock takes: image/png, image/jpeg" }
A connection lost partwaythe interrupt faulta network error once half the file has arrived
Any other errora status fault: "401", "403", "409", "422", "429", "500", "503", "507"…that status, { "error": "HTTP 507 Insufficient Storage (simulated)" }

A route's own override sets its limit and types, and the faults it fails with, in place of the mock's. See Network conditions for every setting, and how each one answers.

Next Steps