What browsers can really do, and how UploadCN uses it.
"use client"
import * as React from "react"
import {
type BackgroundCapabilities,
MiB,
getBackgroundCapabilities,
requestPersistentStorage,
s3Adapter,
} from "@uploadcn/core"
import { CheckIcon, MinusIcon } from "lucide-react"
import { demoTransport } from "@/examples/_demo"
import { Button } from "@/components/ui/button"
import {
UploadProvider,
UploadQueuePanel,
} from "@/components/global-upload"
import { UploadTrigger } from "@/components/ui/upload"
const adapter = s3Adapter({
endpoint: "/api/upload",
transport: demoTransport, // docs only, see examples/_demo.ts
multipart: { threshold: 5 * MiB, partSize: 5 * MiB },
})
const LABELS: Record<keyof BackgroundCapabilities, string> = {
persistence: "IndexedDB file persistence",
persistentStorage: "Persistent storage (eviction protection)",
webLocks: "Web Locks (one tab resumes each upload)",
backgroundFetch: "Background Fetch (Chromium only, needs a service worker)",
}
/**
* Uploads live in an app-level provider, survive navigation, and, with
* `persist`: survive reloads and crashes: they come back paused and resume
* from the last finished part. Browsers can't keep JS uploads running after
* the tab closes; this is the honest, cross-browser approach.
*/
export default function BackgroundUploadExample() {
return (
<UploadProvider adapter={adapter} persist guard>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
<UploadTrigger variant="default">
Upload in the background
</UploadTrigger>
<PersistButton />
</div>
<Capabilities />
</div>
<UploadQueuePanel />
</UploadProvider>
)
}
function PersistButton() {
const [granted, setGranted] = React.useState<boolean | null>(null)
return (
<Button
variant="outline"
onClick={async () => setGranted(await requestPersistentStorage())}
>
{granted === null
? "Request durable storage"
: granted
? "Storage is durable"
: "Browser declined"}
</Button>
)
}
let detected: BackgroundCapabilities | null = null
const subscribe = () => () => {}
// Feature detection only runs in the browser; the server renders nothing.
const getCapabilities = () => (detected ??= getBackgroundCapabilities())
function Capabilities() {
const capabilities = React.useSyncExternalStore(
subscribe,
getCapabilities,
() => null
)
if (!capabilities) return null
return (
<ul
className="flex flex-col gap-1.5 text-sm"
aria-label="Browser capabilities"
>
{(Object.keys(LABELS) as (keyof BackgroundCapabilities)[]).map((key) => (
<li key={key} className="flex items-center gap-2">
{capabilities[key] ? (
<CheckIcon className="size-4" aria-label="Supported" />
) : (
<MinusIcon
className="size-4 text-muted-foreground"
aria-label="Not supported"
/>
)}
<span
className={capabilities[key] ? undefined : "text-muted-foreground"}
>
{LABELS[key]}
</span>
</li>
))}
</ul>
)
}
What's possible
| Goal | Reality in browsers |
|---|---|
| Keep uploading while navigating your app | ✓ Keep the uploader in a layout-level provider |
| Keep uploading in a background tab | ✓ Requests continue (timers may be throttled) |
| Resume after a reload or crash | ✓ Persist files + resume state in IndexedDB |
| Coordinate uploads across tabs | ✓ Web Locks |
| Keep uploading after the tab closes | ✗ Not with JavaScript uploads |
| Background Fetch API | Chromium only; requires a service worker and a single request body, so no multipart, signing per part, or progress per part |
UploadCN implements the first four and reports capabilities honestly with
getBackgroundCapabilities().
App-level uploads
Put UploadProvider in your root layout. Uploads keep running during client-side
navigation, and any component can add files:
<UploadProvider persist>
{children}
<GlobalDropzone />
<UploadQueuePanel />
</UploadProvider>const { addFiles } = useUploadContext()Persistence
persist stores queued files and their resume state in IndexedDB. After a reload or
crash they come back paused and continue from the last finished part. See
Resumable uploads.
Leaving the page
UploadProvider warns before closing or reloading the tab while uploads run
(useUploadGuard). Browsers show their own message; custom text isn't supported.