Tanstack Start

Browser uploads

uploadFiles — parallel files, multipart, retries, and progress.

uploadFiles is the framework-free browser uploader. It talks only to your mounted route handler for the create/complete handshake (so your API key stays server-side), then sends the bytes straight to the bucket. Files and multipart parts upload in parallel, every PUT retries on transient failures, and progress is reported per-file and in aggregate.

The React hooks (useUploadFile / useUploadFiles) wrap this function — reach for them in React. Use uploadFiles directly when you are not on React or want to manage your own state.

import { uploadFiles } from "s3delivery";

const files = await uploadFiles(input.files, {
  maxFileSize: 10 * 1024 * 1024,
  allowedFileTypes: ["image/*"],
  onProgress: (p) => setProgress(p.percent),
});

console.log(files[0].url);

Signature

uploadFiles(
  input: File[] | FileList,
  options?: UploadFilesOptions,
): Promise<S3DeliveryFile[]>;

It resolves with the stored files in the same order as the input selection. An empty selection resolves to []. On any failure it rejects with an S3DeliveryError.

Options

OptionDefaultNotes
endpoint"/api/s3delivery"URL of your mounted route handler.
headersExtra headers for the route-handler requests (e.g. auth). Object or a (possibly async) function returning one.
visibilityroute default"private", "public", or "unlisted".
maxFilesReject if more than this many files are selected (TOO_MANY_FILES).
maxFileSizeReject any file larger than this, in bytes (FILE_TOO_LARGE).
allowedFileTypesAllowed MIME types; exact or type/* wildcards (TYPE_NOT_ALLOWED).
concurrency3Files uploaded in parallel.
partConcurrency6Multipart parts uploaded in parallel, per file.
retries3Max retry attempts per PUT on transient failures.
retryDelay300Base backoff in ms (doubles each attempt, with full jitter).
maxRetryDelay10000Cap on any single backoff wait, in ms.
onProgressAggregate progress across all files.
onFileProgress(file, progress) => void per file.
onFileComplete(result, file) => void as each file finishes.
signalAbortSignal to cancel the whole batch.

Constraints

Constraints are checked client-side, before any URL is requested, so a too-large or wrong-type file fails instantly and locally — no wasted round-trip.

await uploadFiles(input.files, {
  maxFiles: 10,
  maxFileSize: 25 * 1024 * 1024,
  allowedFileTypes: ["image/*", "application/pdf"],
});

Each violation throws an S3DeliveryError with a precise code:

ConstraintError code
maxFilesTOO_MANY_FILES
maxFileSizeFILE_TOO_LARGE
allowedFileTypesTYPE_NOT_ALLOWED

Client constraints are convenience, not security

These give fast, friendly feedback, but a determined client can bypass them. Mirror maxFileSize / allowedFileTypes in your route handler constraints for the authoritative check.

allowedFileTypes matching is case-insensitive, ignores a ; charset=… suffix, and supports "*" / "*/*" (allow all), "image/*" (any subtype), and exact types like "application/pdf".

Concurrency

Two independent dials, both tuned to be fast by default:

  • concurrency (default 3) — how many files upload at once. A batch is not bottlenecked on one slow file.
  • partConcurrency (default 6) — for a large file, how many 16 MB parts upload at once. Multipart kicks in automatically above the server threshold; you do not configure when.
await uploadFiles(files, { concurrency: 5, partConcurrency: 8 });

Retries & backoff

Every PUT retries on transient failures — network errors and 5xx / 429 responses — using exponential backoff with full jitter. Permanent failures (4xx other than 429) and aborts are never retried.

OptionDefaultMeaning
retries3Max retries per PUT (so up to 4 total attempts).
retryDelay300Base delay in ms; the backoff ceiling doubles each attempt.
maxRetryDelay10000Cap on any single wait.

Each attempt waits a random time in [0, min(maxRetryDelay, retryDelay * 2^n)]. Progress for a part resets between attempts, so the aggregate never double-counts a partially-uploaded failed try.

Progress

Progress arrives as { loaded, total, percent } (percent is 0–100, rounded). In the browser, uploads use XMLHttpRequest to report real upload progress; in Node, Bun, Deno, and Workers it falls back to fetch (progress fires once at completion).

await uploadFiles(files, {
  onProgress: (p) => setAggregate(p.percent),
  onFileProgress: (file, p) => setPerFile(file.name, p.percent),
  onFileComplete: (result, file) => console.log(`${file.name} → ${result.url}`),
});

Aborting

Pass an AbortSignal to cancel a batch. In-flight parts and pending retries cancel immediately, and the promise rejects with an S3DeliveryError whose code is ABORTED.

const controller = new AbortController();
const promise = uploadFiles(files, { signal: controller.signal });

cancelButton.onclick = () => controller.abort();

try {
  await promise;
} catch (e) {
  if (e instanceof S3DeliveryError && e.code === "ABORTED") {
    // user cancelled
  }
}

In React, prefer the hooks' control.abort() — same effect, no manual controller.

Auth headers

If your route handler authenticates requests, send credentials with headers:

await uploadFiles(files, {
  headers: async () => ({ authorization: `Bearer ${await getToken()}` }),
});

The function form runs before each handshake request, so you can refresh a token.

On this page