Writing Accessible Upload Error Messages
Every upload error message should answer three questions in plain words: which file, what went wrong, and what the user can do — “holiday.heic is 72 MB, over the 50 MB limit. Export a smaller copy or choose a different photo.” Put the message as text inside the file’s row (not only a red icon), associate it with the row’s controls via aria-describedby, announce it once through the polite live region, keep it until the user acts, and when several files fail, add a summary at the top of the list that counts the failures and links to each. Map technical causes (HTTP status, error codes) to a small catalogue of human messages in one place, so every path through the uploader says the same thing.
Upload errors are unusually frustrating because the user has already done work — chosen files, waited for progress — and the failure often looks arbitrary. WCAG 2.2 criteria 3.3.1 (Error Identification) and 3.3.3 (Error Suggestion) require that errors be described in text and that suggestions be given when known. For upload errors, the fix is almost always known: a smaller file, a different format, a retry, a fresh sign-in. This page belongs to accessible upload interfaces in frontend UX, chunking and progress tracking; batch-level presentation is covered in reporting partial batch failures.
When to use this approach
- Any uploader that can reject or fail files — which is every uploader.
- Multi-file flows, where users need to find the few failures among many successes.
- Products used under stress (applications, claims, medical documents), where unclear errors lead to abandoned tasks.
Prerequisites
- A file list that renders each file’s status as text.
- A polite live region for announcements (announcing upload progress to screen readers).
- Knowledge of your limits: types, sizes, counts, and the server responses that signal each problem.
Anatomy of a good message
Implementation
Keep a single catalogue that turns causes into messages:
export type UploadErrorCause =
| { kind: "too-large"; size: number; limit: number }
| { kind: "wrong-type"; detected: string; allowed: string[] }
| { kind: "too-many"; limit: number }
| { kind: "empty" }
| { kind: "network" }
| { kind: "expired" }
| { kind: "unauthorised" }
| { kind: "quota"; free: number }
| { kind: "rejected-by-scan" }
| { kind: "server"; status: number };
const mb = (b: number) => `${Math.round(b / 1024 / 1024)} MB`;
const list = (xs: string[]) => new Intl.ListFormat("en", { type: "disjunction" }).format(xs);
export function describe(name: string, c: UploadErrorCause): { text: string; retryable: boolean } {
switch (c.kind) {
case "too-large": return { retryable: false, text: `${name} is ${mb(c.size)}, over the ${mb(c.limit)} limit. Export a smaller copy or choose a different file.` };
case "wrong-type": return { retryable: false, text: `${name} is a ${c.detected} file. You can upload ${list(c.allowed)}.` };
case "too-many": return { retryable: false, text: `${name} wasn't added because you can upload up to ${c.limit} files at once. Remove some files first.` };
case "empty": return { retryable: false, text: `${name} is empty (0 bytes). Check the file opens on your device, then choose it again.` };
case "network": return { retryable: true, text: `${name} stopped uploading because the connection dropped. It will retry automatically, or choose Retry.` };
case "expired": return { retryable: true, text: `${name} took too long and the upload link expired. Choose Retry to continue.` };
case "unauthorised": return { retryable: false, text: `You've been signed out, so ${name} couldn't be uploaded. Sign in again; your files will stay in the list.` };
case "quota": return { retryable: false, text: `${name} doesn't fit in your remaining storage (${mb(c.free)} free). Delete some files or upgrade your plan.` };
case "rejected-by-scan": return { retryable: false, text: `${name} was blocked by our security check and can't be uploaded. If you think this is a mistake, contact support.` };
case "server": return { retryable: c.status >= 500, text: `${name} couldn't be uploaded because of a problem on our side. Choose Retry, or try again in a few minutes.` };
}
}
/** Map a failed response to a cause. */
export function causeFromResponse(res: Response, ctx: { limit: number; free?: number; size: number }): UploadErrorCause {
if (res.status === 413) return { kind: "too-large", size: ctx.size, limit: ctx.limit };
if (res.status === 401) return { kind: "unauthorised" };
if (res.status === 403) return { kind: "expired" }; // presigned URL expired or signature mismatch
if (res.status === 507) return { kind: "quota", free: ctx.free ?? 0 };
return { kind: "server", status: res.status };
}
Render the message in the row and wire it to the row’s controls:
function showRowError(row: HTMLElement, id: string, name: string, cause: UploadErrorCause, announce: (t: string) => void) {
const { text, retryable } = describe(name, cause);
let p = row.querySelector<HTMLElement>(".file__error");
if (!p) { p = document.createElement("p"); p.className = "file__error"; p.id = `err-${id}`; row.append(p); }
p.textContent = text;
row.classList.add("has-error");
row.querySelector(".file__status")!.textContent = "Couldn't upload";
for (const btn of row.querySelectorAll("button")) btn.setAttribute("aria-describedby", p.id);
row.querySelector<HTMLElement>(".file__retry")!.hidden = !retryable;
announce(text);
}
Line-by-line on the decisions that matter
- One catalogue. Client-side validation, server responses and background failures all go through
describe, so the same problem always produces the same words. Consistency is what lets users learn the interface. - The file name first. In a list of fifty files, “File too large” does not say which one. Leading with the name also makes the announcement meaningful out of context.
- Numbers with the limit. “72 MB, over the 50 MB limit” tells the user how far off they are, which decides whether compressing will help.
- Retry only when it can succeed. A retry button on a too-large file teaches users that retry is pointless.
retryablehides it for permanent errors. aria-describedbyon the row’s buttons. When a screen-reader user tabs to “Retry” or “Remove”, they hear the reason too, so the decision is informed.- Status word plus message. The short status (“Couldn’t upload”) scans quickly in a list; the full message explains. Colour and an icon reinforce but never replace them.
- Signed out keeps the files. Session expiry during a long upload is common. Keeping the list and resuming after sign-in turns an infuriating loss into a short interruption.
Summaries for batches
When a batch finishes with failures, render a summary above the list: a heading with the count, and one link per failed file with a short reason. Each link targets the row’s id and moves focus there, so keyboard and screen-reader users can jump straight to the file and act. Move focus to the summary heading once, when the batch ends — the user must act, so this is one of the few justified focus moves. Keep the summary until all failures are resolved (removed or retried successfully), and update its count as they are.
For client-side rejections at selection time (wrong type, too large), the same pattern applies at a smaller scale: the files never enter the queue, so show a short list near the dropzone — “2 files weren’t added” with reasons — and announce it. Do not use a modal alert; it blocks the user from fixing the problem, and dismissing it removes the information.
Tone and wording
Write as a helpful person would speak. Avoid blame (“You uploaded an invalid file”), jargon (“MIME type”, “HTTP 413”, “payload”), and vague apology (“Oops! Something went wrong”). Prefer active, specific sentences: “We couldn’t reach the server” rather than “A network error occurred”. Say “couldn’t” rather than “failed” where it reads naturally; it describes the outcome without sounding like a verdict on the user. Keep technical details — an error ID or status code — available for support, in a “Details” disclosure or in logs, not in the main message.
Preventing errors in the first place
The best error message is the one that never appears. WCAG 3.3.2 (Labels or Instructions) asks for constraints up front, and doing so removes most upload errors: state allowed types, maximum size and maximum count next to the input, in the same words the errors use. Validate on selection, before any bytes are sent, so users find out in a second rather than after a long upload. Where you can fix the problem automatically — converting HEIC to JPEG, resizing oversized photos — do it and tell the user rather than rejecting, as in converting HEIC images to JPEG in the browser. Reserve errors for problems only the user can solve.
Configuration gotchas
Errors vanish when the list re-renders. Error text stored only in the DOM is lost on the next render. Keep it in the item’s state and render it from there.
The same failure is announced repeatedly. Retries that fail again re-trigger the announcement. Announce the first occurrence and the final outcome, not every attempt.
Colour-only error rows. A red border fails WCAG 1.4.1 (Use of Color). Always include the status word and the message text.
Server messages shown verbatim. Raw messages like “SignatureDoesNotMatch” leak internals and help nobody. Map every server response through the catalogue.
Verification
- Choose a file over the limit with a screen reader on: you hear the file name, the size, the limit and a suggestion, once.
- Tab to the failed row’s Remove button: the reason is read as its description.
- Fail two files in a batch of ten: the summary heading receives focus at the end and links jump to each row.
- Check every message in the catalogue against the three questions: which file, what happened, what to do.
Frequently Asked Questions
Should errors use role="alert"?
Only for problems that stop everything, such as going offline or being signed out. Per-file errors should be polite, or users hear a stream of interruptions during a batch.
How long should error text stay visible?
Until the user resolves it — by removing, retrying or replacing the file. Toasts that disappear after a few seconds fail users who read slowly or were looking elsewhere.
Do messages need translating separately from the interface?
They are part of the interface. Keep them in the same translation system, with placeholders for names and numbers, and let translators reorder parts of the sentence.