Tanstack Start

Upload engine

uploadToPresigned — the low-level retrying, multipart byte pump.

uploadToPresigned is the raw engine underneath everything else. It takes a file and a presigned upload descriptor and sends the bytes to the bucket: a single PUT for small files, parallel multipart for large ones, with retry, backoff, progress, and abort built in.

Reach for it when you broker the create/complete handshake yourself — for example a dashboard that already has its own authenticated API client and just needs to push the bytes. Most apps should use upload (server) or uploadFiles (browser) instead.

Signature

uploadToPresigned(
  file: Blob,
  upload: UploadDescriptor,
  options?: {
    onProgress?: (progress: UploadProgress) => void;
    concurrency?: number;   // parallel parts (multipart). Default 6.
    signal?: AbortSignal;
    retries?: number;        // default 3
    retryDelay?: number;     // base backoff ms, default 300
    maxRetryDelay?: number;  // cap ms, default 10000
  },
): Promise<{ parts?: CompletedPart[] }>;

The upload descriptor is the upload field returned by createUpload:

type UploadDescriptor =
  | { mode: "single"; url: string; method: "PUT" }
  | {
      mode: "multipart";
      uploadId: string;
      partSize: number;
      parts: { partNumber: number; url: string }[];
    };

For a single descriptor it returns {}. For a multipart descriptor it returns { parts } — the collected part ETags, sorted by part number — which you pass back to complete.

Brokering the handshake yourself

import { uploadToPresigned } from "s3delivery";

// 1. Reserve via your own authenticated API.
const created = await myApi.createUpload({ filename, contentType, size });

// 2. Push the bytes straight to the bucket.
const { parts } = await uploadToPresigned(file, created.upload, {
  onProgress: (p) => setProgress(p.percent),
});

// 3. Confirm.
await myApi.complete(created.file.id, {
  uploadId: created.upload.mode === "multipart" ? created.upload.uploadId : undefined,
  parts,
});

Retry and abort model

This is the engine the retries / retryDelay / maxRetryDelay and signal options on every higher-level uploader flow into.

  • What is retried. Transient failures only: network errors and 5xx / 429 storage responses. Permanent failures (4xx other than 429) and aborts propagate immediately.
  • Backoff. Exponential with full jitter — each retry waits a random time in [0, min(maxRetryDelay, retryDelay * 2^n)], up to retries attempts.
  • Progress resets between attempts. A failed partial attempt's loaded count is reset before the next try, so aggregate progress never double-counts.
  • Aborts are clean. A fired signal cancels in-flight parts and pending retries and rejects with an S3DeliveryError whose code is ABORTED.

Runtime behaviour

In the browser the engine uses XMLHttpRequest (the only way to observe real upload progress). In Node, Bun, Deno, and Cloudflare Workers it uses fetch, and progress fires once on completion.

Multipart needs the ETag exposed

Each multipart part must return its ETag. If your bucket's CORS config does not expose the ETag response header to the browser, the engine throws NO_ETAG. Make sure the bucket exposes ETag for cross-origin PUTs — see Large files & multipart.

On this page