Toast Notifications Are Broken: A Better Pattern for Async Feedback
Toasts get dismissed before anyone reads them, stack into unreadable columns, and vanish for screen reader users. Here's the pattern we use instead when work happens off the main thread.
Toasts were designed for a world where every action finished in under a second. That world is gone. In 2026 half our UI waits on an LLM stream, a webhook, or a background job — and the little slide-in-from-the-corner rectangle is a terrible container for any of it.
This is the pattern we've settled on after too many support tickets that started with "I didn't see any error."
Why toasts fail for real async work
A toast makes three implicit promises: the work is done, the message is short, and the user is looking at the screen. Break any one of those and the pattern collapses.
- The work isn't done. You fire a toast that says "Generating report..." and now you own a lie. If the job fails ten seconds later, where does that error go? Another toast? On top of the first one? What if the user already navigated away?
- The message isn't short. "Payment failed — card declined by issuer, code 05, contact your bank" does not fit in a 320px rectangle without truncation or a scrollbar, both of which look broken.
- The user isn't looking. Auto-dismiss after 4 seconds assumes the user's attention is on the corner of the viewport at the exact moment your async call resolves. It rarely is. They're reading the form they just submitted, or they've tabbed away.
And then there's the accessibility problem. A toast that appears and disappears without an aria-live region is invisible to screen readers. One that uses aria-live="assertive" interrupts whatever the user was doing. Get it wrong in either direction and you've built a feedback system that only works for sighted users with the corner of the screen in their peripheral vision.
The three-lane model
Instead of one notification component doing everything, we split feedback into three lanes based on two axes: is the outcome known? and does the user need to act?
Lane 1: Ephemeral confirmations
These are the toasts you should keep. Outcome is known, action is done, user does not need to do anything. "Copied to clipboard." "Saved." "Message sent." Three-second auto-dismiss, bottom of the screen, aria-live="polite". That's it. Nothing else belongs here.
Lane 2: Persistent status
Anything with a duration — a report being generated, a file uploading, an AI response streaming — goes into a persistent status surface. Not a toast. A dedicated panel or inline region that stays put until the work resolves.
We usually build this as an activity tray anchored to the header or a fixed corner, expandable to show all in-flight jobs. Each job has a stable ID, a title, a state (pending, success, error), and a link to the resulting resource when it exists.
Lane 3: Interrupts
Errors that require a decision, destructive confirmations, session expiry. These are modals or inline blocks, not toasts. If a user has to click something for the app to keep working, hiding the thing they need to click in the corner of the screen for four seconds is malpractice.
Building the persistent status lane
The activity tray is the piece most teams don't have, so it's worth showing what it actually looks like. Here's the shape of the store we use — framework-agnostic, but this is the Zustand version:
type JobState = 'pending' | 'success' | 'error';
interface Job {
id: string;
title: string;
state: JobState;
startedAt: number;
finishedAt?: number;
message?: string;
href?: string;
onRetry?: () => void;
}
interface ActivityStore {
jobs: Record<string, Job>;
start: (job: Omit<Job, 'state' | 'startedAt'>) => void;
resolve: (id: string, patch: Partial<Job>) => void;
dismiss: (id: string) => void;
}
The important detail: start and resolve are separate calls, and the job persists across route changes. That means the code that kicks off a job doesn't need to be alive when it finishes. A user can navigate from the report builder to the dashboard and still see "Q3 report — ready" appear in the tray, with a link.
Usage looks like this:
async function generateReport(params: ReportParams) {
const id = crypto.randomUUID();
activity.start({ id, title: 'Generating Q3 report' });
try {
const report = await api.reports.create(params);
activity.resolve(id, {
state: 'success',
message: 'Ready to view',
href: `/reports/${report.id}`,
});
} catch (err) {
activity.resolve(id, {
state: 'error',
message: err.message,
onRetry: () => generateReport(params),
});
}
}
No toast. The tray shows a spinner next to "Generating Q3 report" for as long as the job runs. When it's done, the row swaps to a success state with a link. If it fails, the row shows the error and a retry button — and it stays there until the user dismisses it.
Announcing state changes to assistive tech
The tray itself is a visible surface, so screen reader users can navigate to it. But state changes still need to be announced. We keep a single aria-live="polite" region at the root of the app and pipe job transitions through it:
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{latestAnnouncement}
</div>
The announcement is short and past-tense: "Q3 report ready." or "Q3 report failed. Retry available in activity tray." Never announce pending — screen reader users don't need "Loading..." said out loud every time something starts.
Motion rules that keep it calm
The activity tray gets busy. Three or four jobs finishing in the same minute is normal. Without motion discipline it feels like a slot machine.
Rules we use:
- One easing curve, two durations. 150ms for state changes within a row (spinner → checkmark), 240ms for rows entering or leaving. Anything else feels inconsistent.
- No bounce, no overshoot. These are status updates, not celebrations. Save spring physics for the confetti when someone completes onboarding.
- Respect
prefers-reduced-motion. Drop all transitions to 0ms and swap slide-in animations for opacity fades. This is a two-line media query and it's non-negotiable. - Stagger, don't stack. If three jobs resolve within 100ms of each other, stagger the state changes by 60ms so the eye can follow. Simultaneous animations read as chaos.
When a toast is still the right answer
We're not banning toasts. We're saying they have one job: acknowledge a completed, low-stakes, user-initiated action. "Copied." "Saved." "Link sent."
Signs you're using a toast when you shouldn't:
- The message has a button in it (except "Undo", which is fine for 5 seconds after a delete).
- The message describes something that hasn't finished yet.
- You've ever considered making the toast "sticky" or "non-dismissible".
- The same toast can appear five times in a row because the user clicked fast.
- You're using
aria-live="assertive"to make sure people notice it.
Any of those and you've outgrown the pattern.
The conversion angle
This isn't just an accessibility exercise. On two recent projects — one B2B dashboard, one e-commerce checkout — moving async feedback out of toasts and into a persistent tray cut a specific class of support ticket ("did my thing work?") noticeably. We won't quote a percentage because the sample sizes were small and the changes shipped alongside other work, but the direction was clear and the tickets that remained were about the underlying job failing, not about the UI hiding the outcome.
There's a straightforward reason. Users who aren't sure their action worked either repeat it (duplicate orders, duplicate uploads) or bail. A visible, persistent status surface removes the ambiguity. That's a conversion pattern dressed up as an accessibility fix.
Where we'd start
If your app fires toasts for anything longer than a network round-trip, pick one flow and rebuild it against the three-lane model. Report generation, file upload, and checkout confirmation are usually the highest-leverage targets. Ship the activity tray as a shared component, wire the aria-live region once at the root, and make it a lint-level rule that new toasts have to justify themselves in review.
If you want a hand auditing async feedback across a product, that's the kind of work we do on our design and frontend engagements. The fix is rarely a bigger notification component — it's usually fewer of them, doing less, in the right place.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
Form Errors Should Move Toward the User, Not Away: Inline Validation That Actually Helps
Most inline validation is punishment dressed up as UX. Here's how to design and build form errors that reduce abandonment instead of causing it.
Design Tokens That Survive Contact With Engineering
Most design token systems die the moment they hit a real codebase. Here's how we structure tokens so they actually get used — and stay in sync between Figma, Tailwind, and native.
Empty States Are Product Surface, Not Filler: A Practical Playbook
Empty states are the first thing new users see and the last thing designers polish. Here's how to treat them as a real product surface instead of a placeholder illustration with a sad robot.
