Chunked uploads

Split large files into parts that upload in parallel and retry independently.

"use client"

import {
  type CreateMultipartResponse,
  MiB,
  type SignPartResponse,
  type StoredObject,
  multipartAdapter,
  postJson,
} from "@uploadcn/core"

import { demoTransport } from "@/examples/_demo"
import {
  Upload,
  UploadDropzone,
  UploadDropzoneDescription,
  UploadDropzoneTitle,
  UploadQueue,
} from "@/components/ui/upload"

/**
 * `multipartAdapter` is provider-agnostic: bring `create`, `uploadPart` and
 * `complete`. Here it talks to the same upload route as `s3Adapter`, with
 * 5 MiB parts, 3 in parallel, each retried independently.
 */
const adapter = multipartAdapter<
  { key: string; uploadId: string },
  StoredObject
>({
  partSize: 5 * MiB,
  concurrency: 3,
  create: ({ file, signal }) =>
    postJson<CreateMultipartResponse>(
      "/api/upload",
      {
        action: "create-multipart",
        file: { name: file.name, type: file.type, size: file.size },
      },
      { signal }
    ),
  async uploadPart({ session, partNumber, blob, signal, onProgress }) {
    const { url } = await postJson<SignPartResponse>(
      "/api/upload",
      { action: "sign-part", ...session, partNumber },
      { signal }
    )
    // Docs only: `demoTransport` plays the bucket when the demo has no
    // credentials. In your app, use `xhrTransport` from @uploadcn/core.
    const response = await demoTransport({
      method: "PUT",
      url,
      body: blob,
      signal,
      onUploadProgress: onProgress,
    })
    return { etag: response.headers.get("etag") ?? "" }
  },
  complete: ({ session, parts, file, signal }) =>
    postJson<StoredObject>(
      "/api/upload",
      {
        action: "complete-multipart",
        ...session,
        parts: parts.map(({ partNumber, etag }) => ({ partNumber, etag })),
        file: { name: file.name, type: file.type, size: file.size },
      },
      { signal }
    ),
  abort: ({ session }) =>
    postJson("/api/upload", { action: "abort-multipart", ...session }).then(
      () => {}
    ),
})

export default function ChunkedUploadExample() {
  return (
    <Upload adapter={adapter}>
      <UploadDropzone>
        <UploadDropzoneTitle>Drop a file larger than 10 MB</UploadDropzoneTitle>
        <UploadDropzoneDescription>
          Split into 5 MB parts · 3 in parallel · per-part retry · pausable
        </UploadDropzoneDescription>
      </UploadDropzone>
      <UploadQueue />
    </Upload>
  )
}

Chunking solves three problems with large files:

  • Reliability: a dropped connection loses one part, not the whole file.
  • Speed: several parts upload in parallel, which saturates bandwidth better than one stream.
  • Resumability: finished parts are recorded, so uploads continue after a pause or reload.

With S3 or R2

Nothing to write: s3Adapter switches to multipart above its threshold.

s3Adapter({ endpoint: "/api/upload", multipart: { threshold: 32 * MiB, partSize: 8 * MiB, concurrency: 4 } })

With any other backend

Use multipartAdapter and implement create, uploadPart and complete against your API. The example above talks to the same route as s3Adapter, written out by hand.

Choosing a part size

  • S3 and R2 require parts of at least 5 MiB (except the last) and at most 10 000 parts. getPartSize grows the part size automatically for huge files.
  • Larger parts mean fewer requests; smaller parts mean less work lost on failure and finer-grained progress. 8–16 MiB is a good default.
  • concurrency of 3–6 is usually optimal; more rarely helps and competes with the page.

Chunk progress

Items expose chunks: { completed, total } alongside byte progress:

const item = useUploadItem()
item.chunks && `${item.chunks.completed}/${item.chunks.total} parts`

On this page