DEV Community

Cover image for My Next.js 16 Optimistic UI Looked Perfect. Then Someone Clicked It Five Times Fast
Shubhra Pokhariya
Shubhra Pokhariya

Posted on

My Next.js 16 Optimistic UI Looked Perfect. Then Someone Clicked It Five Times Fast

Handling race conditions with per-item locks

Everything worked. I'd wired up useOptimistic on a task list, the checkbox flipped the instant you clicked it, no spinner, no half-second lag, exactly the feeling I was going for. I demoed it to myself a dozen times and moved on.

Finish invoice checkbox checked with strikethrough after an optimistic update

Then I did the thing you're supposed to do before you actually ship anything: I clicked it stupidly fast, five times in a row, the way an impatient real user actually would.

The checkbox stayed visually fine. The database did not. Five overlapping requests had gone out, each one flipping the same boolean, landing in whatever order the network felt like that day. Nothing crashed. Nothing logged an error. The UI just quietly stopped matching what actually happened on the server, and I had no way of knowing from looking at the screen.

That's the bug that's easy to miss with optimistic UI specifically, because the whole point of optimistic UI is that it looks right immediately. Looking right and being right are not the same claim.

The fix isn't anything specific to useOptimistic

It's the same rule as disabling a submit button while a form is in flight, just applied per row instead of per form. Track which item has a request pending, and disable it until that request settles.

const [pendingId, setPendingId] = useState<string | null>(null);

function handleToggle(id: string) {
  setPendingId(id);
  startTransition(async () => {
    setOptimisticTask(id);
    try {
      await toggleTask(id);
    } finally {
      setPendingId(null);
    }
  });
}
Enter fullscreen mode Exit fullscreen mode
<input
  type="checkbox"
  checked={task.completed}
  disabled={pendingId === task.id}
  onChange={() => handleToggle(task.id)}
/>
Enter fullscreen mode Exit fullscreen mode

One item at a time, scoped per row, not a global lock on the whole list. The second click on the same checkbox now does nothing instead of firing a second request that races the first one to the database, which also means one fewer redundant write your database has to process for a click that never should have gone out in the first place.

Here's what that actually looks like: five rapid clicks, one request.

Browser Network tab showing only one POST request after five rapid clicks

The rollback question that sent me down a rabbit hole

While I was in there, I went looking for how to properly roll back an optimistic update on failure, since obviously if the toggle fails, the checkbox needs to snap back to whatever it actually was.

Almost every example I found does this:

try {
  setOptimisticTask(id);
  await toggleTask(id);
} catch {
  setOptimisticTask(id); // flip it back again to "undo"
}
Enter fullscreen mode Exit fullscreen mode

It works. It's also solving a problem that, for a plain toggle like this one, doesn't need solving. useOptimistic doesn't give you a second, independent piece of state you own. It gives you a temporary value layered on top of whatever state you passed in, for exactly as long as the transition is pending. The moment that transition settles, success or failure, React drops the temporary layer and renders from the real state again. If the real state never changed, because the request failed and nothing re-fetched, the checkbox reverts on its own. No second dispatch required.

There's one real exception, and it's worth knowing before you assume the automatic revert always has your back. If the optimistic value is something the server hasn't confirmed yet at all, a new comment you added with a placeholder id before the database assigned a real one, there's no prior version of that item sitting in your base state to fall back to. A failed insert doesn't revert a flag, it has to remove something that only ever existed on the client. That's the one case where you keep your own local record of what you added and clear it yourself in the catch block. A toggle doesn't have that problem, since the value it flips already exists on the server either way.

The cache function question I didn't expect to have opinions about

Once the toggle worked, the next question was how to actually invalidate the cache after it. Next.js 16 gives you three ways to do this, and picking the wrong one either shows the user stale data or forces every page on your site to block on a single write.

revalidatePath invalidates one path. revalidateTag invalidates every cache entry sharing a tag, wherever it's used, with stale-while-revalidate behavior. updateTag, Server Actions only, expires a tag immediately so the page the user is currently on reflects their own write right away.

For a checkbox someone is staring at as they click it, updateTag is the right one. If that same task count also fed a sidebar stat somewhere else on the site, one that nobody's watching in real time, revalidateTag on that same tag lets it catch up a beat later instead of forcing every page on the site to block on one write.

One thing worth knowing if you're on a recent Next.js 16 version: revalidateTag now requires a second argument.

revalidateTag("tasks", "max"); // recommended in Next.js 16+
Enter fullscreen mode Exit fullscreen mode

revalidateTag("tasks") alone is deprecated and will throw a TypeScript error.

TypeScript error showing revalidateTag requires two arguments in Next.js 16

The error boundary detail that only matters if your error comes from data fetching

Last piece: what happens when the request just fails outright, not a validation error, an actual dropped connection. Next.js docs are clear that an error thrown inside startTransition still bubbles up to the nearest error.tsx, transition or not.

What I didn't know until I went looking: as of Next.js 16.2, error.tsx ships a second recovery prop, unstable_retry, sitting alongside the older reset. They're not interchangeable. reset wipes the error and lets the same children render again exactly as they were, no new fetch involved, so if the underlying data never loaded in the first place, you're just showing the same broken screen a second time. unstable_retry actually goes back to the server and re-renders the segment fresh, which is the one that fixes anything when the failure came from a fetch.

export default function TaskListError({
  error,
  unstable_retry,
}: {
  error: Error & { digest?: string };
  unstable_retry: () => void;
}) {
  return (
    <div role="alert">
      <p>Something went wrong loading your tasks.</p>
      <button onClick={() => unstable_retry()}>Try again</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

reset still has a place, specifically when you want to clear an error and re-render without touching the network at all. For a segment whose entire job is showing fetched data, that's rarely the case.

Where the rest of this lives

I wrote the full version of this with the complete before/after code, the decision table for all three revalidation functions, and the Suspense boundary pattern for streaming a mutation's result without blocking the rest of the page, over on my site: "I Added Optimistic Updates to a Next.js 16 App. Here's Every Rollback Bug I Hit." It follows directly from the Server Actions bugs I wrote up in an earlier post, if you want the context on useActionState and why redirect can't sit inside a try/catch.

Has anyone else had an optimistic UI look perfect in every demo and then fall apart the moment a real user clicked faster than you did? I'd genuinely like to know if the rapid-click case is as common as it felt once I actually looked for it.

Note: AI used for image editing.

Top comments (22)

Collapse
 
hemapriya_kanagala profile image
Hemapriya Kanagala

I'm not a React/Next.js developer, but the lesson here applies everywhere. It's easy to test the "happy path" and forget what happens when users click faster than expected. Nice reminder that real users don't always behave like our demos 😄

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thanks! Yeah, that gap between "works in my demo" and "works" is such an easy one to miss until a real user closes it for you. Appreciate you reading it, glad the lesson traveled outside React!

Collapse
 
webdeveloperhyper profile image
Web Developer Hyper

Great debugging post as always! 😀 Users sometimes use an app in ways we never expect, so it's hard to prevent every bug. 😅 I'd like to think about as many possible user behaviors as I can when building apps.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thanks, happy to hear that! And yeah, that's pretty much the job. Half of debugging is just thinking, "okay, but what if someone does this weird thing?" before it actually happens to you. The five-click thing felt so dumb in retrospect too. Like, of course someone's gonna do that, but it never occurs to you until it happens.

Collapse
 
nazar-boyko profile image
Nazar Boyko

Scoping the pendingId lock to the row instead of the whole list is exactly right, and it's the part people get wrong first. One thing that might bite later: disabling only stops clicks that go through the checkbox, so if the same toggle can also fire from a keyboard shortcut or a re-render somewhere else, the guard has a gap. Keying the pending state off the item id (which you're already doing) rather than a plain boolean is what saves you there. Honestly the bit I hadn't thought about clearly before was your point that the auto-revert already handles rollback for a plain toggle, since most examples flip it back by hand and never explain why that's redundant.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Nazar, really good catch on the keyboard shortcut gap. Hadn't even thought about that while writing this. You're right, it's the id-keyed state doing the real work there, not the disabled attribute. The checkbox just happened to be the only path I was testing against. If a shortcut or something else could fire the same toggle, checking pendingId at the top of handleToggle would still catch it, even with the input not disabled. Not a real gap in this specific app since a checkbox is the only thing that can toggle a checkbox, but definitely worth flagging for anyone extending this into other trigger paths.

And that rollback point took me a minute to actually convince myself of too. I went in assuming I'd need the manual flip-back, same as every example does. It wasn't until I traced through what useOptimistic actually does on settle that I saw the manual version was just doing twice the work for nothing.

Collapse
 
alexshev profile image
Alex Shev

Optimistic UI always looks clean in the single-click demo. The real test is repeated intent, delayed responses, and partial failure. I like treating every optimistic action as a tiny distributed system: local state, server truth, replay, and reconciliation.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Alex, I like the "tiny distributed system" way of framing it. Repeated intent was exactly what the five-click test exposed, and looking back it maps pretty well to what I ended up debugging. Appreciate you reading.

Collapse
 
alexshev profile image
Alex Shev

Yes, that five-click test is the perfect example. Optimistic UI is not really a UI trick once users can repeat intent quickly; it becomes a reconciliation problem. I like testing it with duplicate clicks, delayed responses, and one forced failure before trusting the happy path.

Collapse
 
mudassirworks profile image
Mudassir Khan

the unstable_retry vs reset distinction is the one that bites first. we spent a sprint on a broken error screen — it was reset, which just rerenders without a server round trip. took us too long to figure out reset and settle are not the same thing.

the place it matters: data fetch failures behind a stale ISR cache. reset surfaces the cached version. unstable_retry forces a fresh render. for a task checkbox fine either way. for a shared dashboard with a 5 minute cache, completely different call.

was the revalidateTag second argument a 16.1 addition or was it there earlier?

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Mudassir, really good extension of the reset vs unstable_retry point, the ISR case is exactly where it stops being cosmetic. reset re-showing the stale cached version instead of fetching is the kind of bug that looks fine in a demo and quietly lies to users on a real dashboard.

On the version question: it was there from 16.0, not a 16.1 addition. It's in the original Next.js 16 blog post and the beta announcement before that, so anyone on 16.0+ already needs the second argument, not just people who've updated further.

Collapse
 
divineuzor profile image
Divine Uzor

The real lesson here for me, is that UI polish and backend reliability aren't separate concerns. One user clicking too fast shouldn't put load on your infrastructure. That's a design problem, not just a UX problem. Simple lock prevents so much downstream chaos.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Divine, exactly. It's easy to file this under a UI bug when it's really a design problem. Five clicks becoming one request isn't just smoother for the user, it's one less write the database has to process. That simple lock protects the backend just as much as it improves the UI.

Collapse
 
frank_signorini profile image
Frank

How did you handle cases where the user clicks multiple times in quick succession with useOptimistic, was it a custom implementation? I'm following your work for more insights on Next.js optimizations.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Frank, good question. useOptimistic doesn't handle this on its own, it just manages the temporary value, nothing about whether a second request should fire. So the multi-click part is custom: a pendingId state tracking which row has a request in flight, and that row's checkbox stays disabled until it settles. Basically the same idea as disabling a submit button while a form's in flight, just scoped to one row instead of the whole form. Appreciate you following along, more Next.js stuff coming.

Collapse
 
publiflow profile image
PubliFlow

Dealing with rapid clicks on optimistic UI updates is a classic race condition headache. I usually end up implementing a debounce or a queueing mechanism on the client side to batch those rapid state changes before they hit the server mutation. It prevents the UI from flickering wildly when the server responses finally catch up to the queued actions. I actually had to solve a similar concurrency issue when wiring up the real-time subscriptions in our Next.js SaaS boilerplate, PubliFlow.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Good point. Queueing makes a lot of sense for workflows where every action needs to be preserved and processed in order, like real-time subscriptions.

For this case, I wanted the opposite. A task toggle is idempotent, so five rapid clicks should end up in the same state as one. The per-row pending lock keeps it to one mutation at a time for that item, avoids redundant writes, and keeps the optimistic UI and the server from drifting out of sync.

Collapse
 
alexshev profile image
Alex Shev

Optimistic UI is one of those features that feels finished until the user behaves like a user. Fast repeated actions expose whether the state model is actually coherent. The visual optimism is easy; the hard part is making rollback, dedupe, pending state, and server truth feel like one system.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

That's a great way to look at it, Alex.

I really like the idea of thinking about it as one system instead of a collection of separate pieces. Looking back, that's exactly what the five-click case exposed. The optimistic update alone wasn't the hard part, it was everything around it that had to stay in sync.

Collapse
 
alexshev profile image
Alex Shev

Exactly. Optimistic UI is less a UI trick and more a distributed-state problem wearing a friendly face. The five-click case is useful because it exposes whether the client, server, retry behavior, disabled state, and final reconciliation are actually one system or just several pieces hoping the happy path holds.

Collapse
 
james_nava_88d210faf4702a profile image
James Nava

Am so grateful to the people who referred me to this hacker called jbeespyhack ❊ @ ❊ gm a il c om, I got what I paid for in full after my contact with this hacker, I got a complete iPhone 16 phone hacked and also a Samsung phone hacked and they all came out successfully with no trace left.. jbeespyhack AT gMail . C0m you indeed did a good job for me thank you