Reporting Partial Batch Failures
Finish every batch with an honest summary — “46 of 50 uploaded, 4 need attention” — that keeps the successful files committed, groups the failures by cause (“2 are over the 50 MB limit”, “2 lost connection”), and attaches one action per group: Retry for transient causes, Remove or Replace for permanent ones, and Retry all failed for the batch. Keep the failed files in the list with their reasons until the user resolves them, reconcile the final state with your server before declaring anything uploaded, and let users download a list of what failed if the batch was large. Never roll back successes because of a few failures, and never hide failures behind a green “Done”.
A batch is a promise to the user that their files will arrive. When most arrive and a few do not, the interface must make three things obvious: that the successful files are safe, which files failed and why, and what to do about them. Most uploaders fail at one of these — a generic “Some uploads failed” toast, a red icon on rows the user has scrolled past, or a retry button that re-uploads everything. This page belongs to upload error recovery patterns in frontend UX, chunking and progress tracking; message wording is covered in writing accessible upload error messages.
When to use this approach
- Users upload several files at once — galleries, document packs, folder imports.
- Some failures are expected: size or type limits, flaky networks, quota, virus scanning.
- Downstream steps (submitting a form, publishing an album) depend on which files made it.
Prerequisites
- Per-file outcomes with a classified cause (the catalogue from the accessible error messages guide works well).
- A server endpoint that confirms uploads and can report which files it has for a batch.
- A queue that can retry individual items (prioritizing and pausing items in an upload queue).
Anatomy of the summary
Implementation
type Cause = "too-large" | "wrong-type" | "network" | "expired" | "quota" | "blocked" | "server" | "unauthorised";
interface Outcome { id: string; name: string; size: number; ok: boolean; cause?: Cause; message?: string; key?: string }
const RETRYABLE: Record<Cause, boolean> = {
"too-large": false, "wrong-type": false, network: true, expired: true,
quota: false, blocked: false, server: true, unauthorised: false,
};
const GROUP_LABEL: Record<Cause, (n: number) => string> = {
"too-large": (n) => `${n} ${n === 1 ? "file is" : "files are"} over the size limit`,
"wrong-type": (n) => `${n} ${n === 1 ? "file is" : "files are"} a type we can't accept`,
network: (n) => `${n} ${n === 1 ? "file" : "files"} lost the connection`,
expired: (n) => `${n} ${n === 1 ? "file" : "files"} took too long and timed out`,
quota: (n) => `${n} ${n === 1 ? "file doesn't" : "files don't"} fit in your storage`,
blocked: (n) => `${n} ${n === 1 ? "file was" : "files were"} blocked by our security check`,
server: (n) => `${n} ${n === 1 ? "file" : "files"} hit a problem on our side`,
unauthorised: (n) => `You were signed out before ${n} ${n === 1 ? "file" : "files"} finished`,
};
export function summarise(outcomes: Outcome[]) {
const ok = outcomes.filter((o) => o.ok);
const failed = outcomes.filter((o) => !o.ok);
const groups = new Map<Cause, Outcome[]>();
for (const f of failed) groups.set(f.cause ?? "server", [...(groups.get(f.cause ?? "server") ?? []), f]);
return {
headline: failed.length === 0
? `${ok.length} ${ok.length === 1 ? "file" : "files"} uploaded`
: `${ok.length} of ${outcomes.length} uploaded · ${failed.length} need${failed.length === 1 ? "s" : ""} attention`,
groups: [...groups.entries()]
.sort(([a], [b]) => Number(RETRYABLE[b]) - Number(RETRYABLE[a])) // retryable groups first
.map(([cause, files]) => ({ cause, label: GROUP_LABEL[cause](files.length), retryable: RETRYABLE[cause], files })),
retryableIds: failed.filter((f) => RETRYABLE[f.cause ?? "server"]).map((f) => f.id),
};
}
/** Before showing "uploaded", confirm with the server which files it really has. */
export async function reconcile(batchId: string, outcomes: Outcome[]): Promise<Outcome[]> {
const res = await fetch(`/api/batches/${batchId}/files`);
const onServer: Set<string> = new Set((await res.json()).map((f: { key: string }) => f.key));
return outcomes.map((o) =>
o.ok && o.key && !onServer.has(o.key) ? { ...o, ok: false, cause: "server", message: "We couldn't confirm this file arrived." } : o,
);
}
/** CSV export for large batches. */
export function failuresCsv(outcomes: Outcome[]): Blob {
const rows = [["file", "size_bytes", "reason"], ...outcomes.filter((o) => !o.ok).map((o) => [o.name, String(o.size), o.message ?? o.cause ?? ""])];
const csv = rows.map((r) => r.map((c) => `"${c.replace(/"/g, '""')}"`).join(",")).join("\r\n");
return new Blob([csv], { type: "text/csv" });
}
Line-by-line on the decisions that matter
- Headline states the outcome in numbers. “46 of 50 uploaded” answers the first question users have — is my stuff safe — before any detail.
- Grouped by cause. Four failures with four different messages are harder to act on than two groups with one action each. Grouping also scales: a thousand-file import with 30 failures usually has two or three causes.
- Retryable groups first. Problems the user can fix with one click come before problems that need them to find another file.
retryableIdsfor “Retry all failed”. The batch-level retry touches only transient failures. Re-uploading a too-large file would just fail again and erode trust in the button.- Reconciliation before success. The client’s view can be wrong: a completion request timed out after the server processed it, or a background scan rejected a file after upload. Asking the server which files it has turns the summary into a statement of fact.
- CSV export. For big imports, users need to fix failures outside your app — find originals, re-export, compress. A downloadable list makes that practical.
Transactional or independent?
Some batches only make sense complete — an application that needs a passport scan, a photo and proof of address. Even then, do not make the upload itself all-or-nothing. Keep each file as it arrives, and enforce completeness at the step that consumes the batch: the submit button stays disabled with a clear “Still needed: proof of address” until every required slot has a successful file. Users can leave and come back, and a failure in one slot never costs them the others.
Failures that happen after “uploaded”
Some failures only appear after the bytes arrive: a virus scan blocks a file, transcoding finds it unreadable, a moderation check rejects it. These must flow back into the same summary. Keep the batch view subscribed to processing events — server-sent events or polling — and move a file from “Uploaded” to “Needs attention” with the cause when a late failure arrives, announcing it politely. If the user has already left the page, notify them through the product’s normal channel (an in-app notification or email) with a link back to the batch. Upload completion events covers the server side of those signals.
Measuring failure causes
The grouped causes are also your best product data about uploads. Send one event per finished batch with the counts per cause — never file names or contents — and chart them over time. A steady share of “over the size limit” suggests the limit or its explanation needs attention; a spike in “lost the connection” after a release suggests a regression in retry logic; “blocked by our security check” rising for one customer is a signal for your security team. Pair the counts with the device and connection type where you already collect them, since mobile networks produce very different failure mixes from office broadband.
Review the top causes regularly and ask, for each, whether the user could have been spared the failure: a clearer limit next to the input, automatic image resizing, a longer URL lifetime, or background retries. Every cause you prevent is one less group in the summary.
Configuration gotchas
“Retry all” re-uploads successful files. The retry list was built from all files, not from failed, retryable ones. Use retryableIds.
Summary says 50 uploaded, server has 48. Completion was assumed from the storage PUT’s 200, but the confirm call failed. Reconcile with the server before showing the final summary.
Failures are only visible by scrolling. Rows show errors but the summary does not. Always put counts and grouped causes at the top, with links to rows.
Signed-out failures lose the batch. Re-authentication reloads the page and clears in-memory state. Persist the batch (IndexedDB) or re-authenticate in a pop-up or iframe so the page survives.
Verification
- Upload 20 files including two over the limit and one blocked by DevTools request blocking: the summary shows “17 of 20 uploaded · 3 need attention” with two groups.
- Press “Retry all failed” after unblocking: only the network failure retries; the oversized files are untouched.
- Delete one uploaded object on the server before the batch ends: reconciliation moves it to “needs attention”.
- Download the CSV and check it lists exactly the failed files with reasons.
Frequently Asked Questions
Should a partial batch show as success or failure?
Neither, alone. Show both numbers. The overall styling can be neutral or cautionary, but the successful count must be visible and reassuring.
How long should failed files stay in the list?
Until the user removes, replaces or successfully retries them, or leaves the page. If the batch is persisted, keep failures across visits so users can come back to fix them.
Should the app retry transient failures automatically at the end?
One automatic pass at the end of the batch, with backoff, is reasonable and fixes most network blips before the user sees them. After that, leave retries to the user.