Resilient file uploads using background sync
A reliable retry starts with durable data. This example stores small files in IndexedDB before attempting an upload, then replays the queue from a page or service worker. It keeps the original article’s discussion of chunking and workers, while separating those techniques from durable retry.
Background Sync is best effort: browsers can stop a worker, evict site data, or decline to schedule another attempt. It requires a secure context and is not universally available. Feature-detect it and keep an explicit foreground retry, as described by MDN’s Background Synchronization guide.
Understanding chunked uploads
Chunking reduces the amount resent after an interruption. Concurrency may help a particular connection, but extra requests can also increase contention. Neither splitting a Blob nor creating a Worker makes a queue durable. A File object held only in page memory disappears on reload.
Implementing chunked uploads
The runnable flow here deliberately accepts at most ten files of 10 MiB each and sends each file as one request. It retries whole files. For larger files, use a resumable protocol and persist its upload URL and offset alongside the Blob; do not concatenate anonymous chunks on a server.
The complete browser files below require an existing, same-origin authenticated application with this server contract. They do not implement authentication, storage or an upload backend:
| Endpoint | Required contract |
|---|---|
GET /api/upload-session | Return {owner, csrf} for the current cookie session with Cache-Control: no-store; never enable cross-origin reads. Return 401 when logged out. |
POST /api/queued-uploads | Require the session, valid X-CSRF-TOKEN, and X-Upload-Owner matching that session. Accept multipart field file and a UUID Idempotency-Key. |
The server must bind (owner, key) to the content digest and size in durable storage, serialize
concurrent attempts, reject changed content with 409, and return the same {id} on a successful
replay. On both the initial success and every successful replay, id must exactly equal the
submitted Idempotency-Key (entry.id in queue.js), not a server-generated object ID.
Enforce the file limit, media policy, request timeout, per-user quota and rate limit there.
Stage bytes privately; validate and scan as required, then atomically publish the object and its
receipt. Return 200 or 201 only after this commit, never 202. Retain receipts for at least the
queue’s 24-hour lifetime plus a retry grace period. Reject expired keys after that window rather
than treating an old replay as a new upload. Retain a durable expiration record for each
(owner, key) after receipt cleanup and check it before accepting an upload, so an expired key
can never become new again. No third-party Auth Secret is sent to the browser.
Parallel uploads with Web Workers
Fetch already performs asynchronous network I/O. This example uses one request at a time and an origin-wide Web Lock, including across tabs and the service worker. That avoids an unbounded worker per chunk and leaves no dedicated workers to terminate on failure. A service worker can still be killed between server commit and local deletion; the stable idempotency key makes that replay safe.
The page requires IndexedDB and Web Locks. If either is unavailable, show a clear unsupported message and use your application’s direct upload flow instead. Background Sync itself is optional.
Using the Tus protocol for resumable uploads
tus supplies offset negotiation for partial uploads. Its server still needs ownership, quotas and private staging. A tus upload URL is not a public download URL, and local URL storage does not preserve the original file’s bytes. See the resumable client example for an alternative to retrying small files in full.
Integrating background sync
Install the pinned IndexedDB promise wrapper and bundler in a separate client project:
corepack yarn add --exact idb@8.0.3
corepack yarn add --dev --exact esbuild@0.27.3
Create queue.js. Only IndexedDB requests are awaited inside a transaction; the network request
finishes before a new deletion transaction begins. Awaiting tx.done ensures writes committed.
The distinction is explained in
idb’s transaction documentation.
import { openDB } from 'idb'
const maxSize = 10 * 1024 * 1024
const lifetime = 24 * 60 * 60 * 1000
const lockName = 'durable-file-uploads'
async function database() {
return openDB('durable-file-uploads', 1, {
upgrade(db) {
db.createObjectStore('uploads', { keyPath: 'id' })
},
})
}
export async function enqueue(file, owner) {
if (!owner || !(file instanceof File) || file.size === 0 || file.size > maxSize) {
throw new Error('Choose a file between 1 byte and 10 MiB while signed in.')
}
const db = await database()
try {
const tx = db.transaction('uploads', 'readwrite')
const id = crypto.randomUUID()
// Observe request and transaction failures together, including quota-induced aborts.
await Promise.all([
(async () => {
// Counting and inserting in one transaction also bounds concurrent tabs.
if ((await tx.store.count()) >= 10) {
throw new Error('The queue is full. Retry or discard queued files first.')
}
await tx.store.add({ id, owner, file, name: file.name, created: Date.now() })
})(),
tx.done,
])
return id
} finally {
db.close()
}
}
export async function drain(signal = new AbortController().signal) {
return navigator.locks.request(lockName, { signal }, async () => {
const db = await database()
try {
// The count is bounded at ten. No readwrite transaction spans a fetch.
const entries = await db.getAll('uploads')
if (entries.length === 0) return
const sessionResponse = await fetch('/api/upload-session', {
credentials: 'same-origin',
cache: 'no-store',
redirect: 'error',
signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
})
if (!sessionResponse.ok) throw new Error('Sign in again before retrying.')
const session = await sessionResponse.json()
if (
typeof session.owner !== 'string' ||
!session.owner ||
typeof session.csrf !== 'string' ||
!session.csrf
) {
throw new Error('Invalid upload session.')
}
for (const entry of entries) {
signal.throwIfAborted()
if (entry.owner !== session.owner) continue
if (Date.now() - entry.created >= lifetime) {
throw new Error('A queued file expired. Discard it before retrying.')
}
const body = new FormData()
body.append('file', entry.file, entry.name)
const response = await fetch('/api/queued-uploads', {
method: 'POST',
credentials: 'same-origin',
redirect: 'error',
body,
headers: {
'X-CSRF-TOKEN': session.csrf,
'X-Upload-Owner': entry.owner,
'Idempotency-Key': entry.id,
},
signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
})
if (!response.ok || ![200, 201].includes(response.status)) {
throw new Error(`Upload not confirmed (HTTP ${response.status}).`)
}
const receipt = await response.json()
if (receipt.id !== entry.id) throw new Error('Invalid upload receipt.')
const tx = db.transaction('uploads', 'readwrite')
await Promise.all([tx.store.delete(entry.id), tx.done])
}
} finally {
db.close()
}
})
}
export async function discard(owner) {
// Serialize with uploads so a deletion cannot race a foreground or background replay.
await navigator.locks.request(lockName, async () => {
const db = await database()
try {
const tx = db.transaction('uploads', 'readwrite')
await Promise.all([
(async () => {
for (const entry of await tx.store.getAll()) {
if (entry.owner === owner) await tx.store.delete(entry.id)
}
})(),
tx.done,
])
} finally {
db.close()
}
})
}
Create upload-sw.js. A rejected waitUntil leaves the queue intact and tells the browser that the
sync attempt failed. A later retry fetches a fresh CSRF token instead of persisting credentials.
import { drain } from './queue.js'
self.addEventListener('install', (event) => event.waitUntil(self.skipWaiting()))
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()))
self.addEventListener('sync', (event) => {
if (event.tag === 'file-upload-sync') event.waitUntil(drain())
})
In your authenticated server-rendered page, output the current user’s opaque ID in the escaped
data-upload-owner attribute on <html>. Include these controls and the compiled module:
<label>File <input id="file" type="file" /></label>
<button id="queue" type="button">Queue file</button>
<button id="retry" type="button">Retry queued files</button>
<button id="stop" type="button">Stop foreground retry</button>
<button id="discard" type="button">Discard my queued files</button>
<p id="status" role="status"></p>
<script type="module" src="/upload-page.js"></script>
Create upload-page.js. Saving succeeds even if registration or sync scheduling is refused; the
explicit retry uses the same queue without Background Sync.
import { discard, drain, enqueue } from './queue.js'
const owner = document.documentElement.dataset.uploadOwner
const input = document.getElementById('file')
const status = document.getElementById('status')
let controller = null
async function retry() {
if (controller) return
controller = new AbortController()
try {
await drain(controller.signal)
status.textContent = 'Retry finished. Other accounts’ files remain queued.'
} catch {
status.textContent =
'Retry stopped or failed. Sign in as the original owner and retry, or discard expired files.'
} finally {
controller = null
}
}
document.getElementById('queue').onclick = async () => {
const file = input.files?.[0]
if (!file) return
try {
await enqueue(file, owner)
input.value = ''
status.textContent = 'Saved locally. Use Retry queued files to upload now.'
} catch {
status.textContent =
'Could not save. Check the 10 MiB file limit, ten-file queue limit, and available storage.'
return
}
try {
if ('serviceWorker' in navigator) {
await navigator.serviceWorker.register('/upload-sw.js')
const registration = await navigator.serviceWorker.ready
if ('sync' in registration) await registration.sync.register('file-upload-sync')
}
} catch {
status.textContent = 'Saved locally. Background retry unavailable; use Retry queued files.'
}
}
document.getElementById('retry').onclick = retry
document.getElementById('stop').onclick = () => controller?.abort()
document.getElementById('discard').onclick = async () => {
controller?.abort()
try {
await discard(owner)
status.textContent = 'Local queue discarded. Already accepted uploads remain on the server.'
} catch {
status.textContent = 'Could not discard queued files.'
}
}
if (!owner || !('indexedDB' in globalThis) || !navigator.locks) {
document.getElementById('queue').disabled = true
document.getElementById('retry').disabled = true
document.getElementById('discard').disabled = true
status.textContent = 'Durable uploads require sign-in, IndexedDB, and Web Locks.'
}
Bundle both entry points, then serve public/ from your authenticated application’s HTTPS origin:
corepack yarn esbuild upload-page.js --bundle --format=esm --outfile=public/upload-page.js
corepack yarn esbuild upload-sw.js --bundle --format=iife --outfile=public/upload-sw.js
Best practices
Treat IndexedDB as a local copy subject to quota and eviction. Ask users before retaining sensitive files on shared devices. Discard the outgoing account’s queue during logout before changing accounts; the server must still reject a stale owner header. Stopping foreground retry does not stop a separately scheduled background attempt or roll back a committed upload. Server-side cancellation requires its own authenticated, serialized operation.
Verify offline saving, reload, quota failure, expired sessions, account switching, HTTP 429/500, malformed receipts, and a worker stopped immediately after the server commits. On permanent validation errors, the user must discard the queue and fix the file; retries do not fix bad input.
Conclusion
Durability comes from committed IndexedDB writes and idempotent server publication. Background Sync provides an additional opportunity to retry; the foreground control remains essential. For larger uploads, combine a durable file source with a resumable server protocol instead of increasing worker count without measuring the effect.
