Back to React
React
medium
mid

How would you queue toasts so they do not all stack at once?

Keep two lists: visible (capped at N) and a queue. When a toast is dismissed, promote the next queued one. Track timers per visible toast. Optionally de-dupe and prioritize errors. The key is separating 'added' from 'shown'.

4 min read·~12 min to think through

The fix is separating "a toast was requested" from "a toast is on screen."

The data model

js
const MAX_VISIBLE = 3;
// state:
//   visible: Toast[]   — currently rendered, max MAX_VISIBLE
//   queue:   Toast[]   — waiting for a free slot

When add(toast) is called:

  • If visible.length < MAX_VISIBLE → push into visible, start its dismiss timer.
  • Else → push into queue.

When a toast is dismissed (timer or user):

  • Remove it from visible.
  • If queue is non-empty → shift() the next one into visible and start its timer.
js
function dismiss(id) {
  setVisible((v) => {
    const next = v.filter((t) => t.id !== id);
    if (queueRef.current.length && next.length < MAX_VISIBLE) {
      const promoted = queueRef.current.shift();
      startTimer(promoted);
      return [...next, promoted];
    }
    return next;
  });
}

Refinements that show seniority

  • Timers only for visible toasts — a queued toast's duration shouldn't count down while it's invisible. Start the timer on promotion, not on add.
  • Priority — errors jump the queue (unshift instead of push, or sort by priority) so a critical error isn't stuck behind info toasts.
  • De-duplication — if the same message is added repeatedly, collapse it (or show a "×3" counter) instead of queueing duplicates.
  • Queue cap — even the queue should have a limit; drop or coalesce beyond it so a runaway loop doesn't grow memory unboundedly.
  • Group/replace — a "Saving…" toast replaced by "Saved" rather than stacking.

The framing

"The bug is treating 'added' and 'shown' as the same thing. I keep two lists — a visible array capped at N with live timers, and a queue for the rest. On dismiss, I promote the next queued toast and start its timer then — so duration only counts while on screen. From there: errors get priority, identical toasts de-dupe, and the queue itself is capped."

Follow-up questions

  • Why start a queued toast's timer on promotion rather than on add?
  • How would you let error toasts jump the queue?
  • How do you handle 100 identical toasts fired in a loop?
  • Should the queue itself have a maximum size?

Common mistakes

  • Starting the dismiss timer when the toast is added, so queued toasts expire while invisible.
  • No cap on the queue — unbounded memory growth.
  • Treating all toasts as equal priority, burying critical errors.
  • Not de-duplicating, so a render loop spams identical toasts.

Performance considerations

  • The queue can grow unbounded under a runaway loop — cap it and coalesce duplicates. Keep per-toast timers in a ref keyed by id and clear them on dismiss/unmount to avoid leaks.

Edge cases

  • Queue grows faster than toasts are dismissed.
  • A sticky (no-duration) toast occupying a visible slot forever.
  • User manually dismisses a toast — must immediately promote from the queue.
  • Identical messages fired rapidly.

Real-world examples

  • Sonner and react-hot-toast cap visible toasts and stack the rest.
  • Chat apps coalescing rapid notifications into a grouped one.

Senior engineer discussion

Seniors immediately separate 'added' from 'shown', cap the visible set, and crucially start timers on promotion. They then layer priority for errors, de-duplication, and a queue cap so a runaway producer can't exhaust memory.

Related questions