DEV Community

Cover image for The Optimistic UI Race Condition That Only Showed Up on the Fifth Click
Shubhra Pokhariya
Shubhra Pokhariya

Posted on

The Optimistic UI Race Condition That Only Showed Up on the Fifth Click

Summer Bug Smash: Smash Stories šŸ›šŸ›¹

This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.

I originally shared this debugging story on July 8. When Bug Smash was announced, it immediately came to mind because nothing crashed, nothing logged an error, and the application was still wrong.

The setup

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

That was the trap. Optimistic UI is designed to look right immediately. Looking right and being right are not the same claim.

The moment I stopped trusting the demo

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 is the bug that is easy to miss with optimistic UI specifically, because the whole point of optimistic UI is that it looks right immediately.

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.

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

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

One comment made me think harder

After the original post went up, Nazar Boyko left a comment that stuck with me.

He pointed out something I hadn't really separated in my own head. Disabling the checkbox only blocks clicks that go through that specific input. If the same toggle can ever be triggered another way, like a keyboard shortcut or somewhere else in the app, the disabled attribute is only one layer of protection. The real guard is the id keyed pending state, because that prevents the same operation from starting again regardless of how it was triggered.

He also mentioned that the rollback explanation was the part that clicked for him. Most examples manually flip the state back in a catch block without explaining why. Realizing that a plain toggle doesn't need that extra step was one of the biggest things I learned while digging into useOptimistic, so it was nice to hear that it helped someone else too.

That's one of my favorite things about writing technical posts in public. Sometimes someone doesn't find a bug in your code. They help you explain the idea more clearly or think about it from a different angle.

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, does not need solving. useOptimistic does not 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 is one real exception. If the optimistic value is something the server has not confirmed yet at all, a new comment you added with a placeholder id before the database assigned a real one, there is no prior version of that item sitting in your base state to fall back to. A failed insert does not revert a flag, it has to remove something that only ever existed on the client. That is the one case where you keep your own local record of what you added and clear it yourself in the catch block. A toggle does not 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 the site to block on a single write.

For a checkbox someone is staring at as they click it, updateTag is the right one, since it expires the tag immediately so the page the user is currently on reflects their own write right away. If that same task count also fed a sidebar stat somewhere else on the site, one nobody is watching in real time, revalidateTag on that same tag lets it catch up a beat later instead.

One thing worth knowing if you are 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

What changed for me

I used to demo optimistic UI by clicking once.

Now I deliberately try to break it. Five rapid clicks. Slow networks. Repeated interactions.

Race conditions rarely announce themselves. They don't throw exceptions. They don't light up your console. They quietly wait for a user who is just a little more impatient than you were during development.

That checkbox looked perfect every time I demonstrated it. The bug was there the whole time. It was simply waiting for me to test it like a real user.

If you'd like the full walkthrough, including the rollback patterns, cache decisions, and the rest of the implementation, I've put it all here: shubhra.dev/tutorials/nextjs-16-useoptimistic-rollback-pattern

Top comments (27)

Collapse
 
nazar-boyko profile image
Nazar Boyko

Quick one on the revalidateTag part. You mention revalidateTag("tasks") with a single argument is deprecated and throws a TS error in Next 16, but I thought the second "profile" argument was still optional in stable 16. Is that from a canary, or did the one-arg form actually get removed in a stable release?

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Good question. It's not from a canary. This is stable Next.js 16 behavior.

The single-argument form is deprecated, and the current TypeScript signature requires the second profile argument:

revalidateTag(tag: string, profile: string | { expire?: number }): void;
Enter fullscreen mode Exit fullscreen mode

So revalidateTag("tasks") triggers a TypeScript error, and that's exactly what the screenshot in the post is showing. It still works at runtime if you suppress the type error, but the recommended form is:

revalidateTag("tasks", "max");
Enter fullscreen mode Exit fullscreen mode

(or updateTag("tasks") in Server Actions when you want immediate read-your-own-writes.)

Collapse
 
publiflow profile image
PubliFlow

Optimistic UI race conditions are notoriously tricky because they often depend on network latency variations that are hard to reproduce locally. I have seen this happen frequently with rapid sequential mutations where the local state gets out of sync with the server if the second request resolves before the first one. Have you considered implementing a request queue or using a library like React Query to handle the mutation states and automatically invalidate the cache after the final successful response? It usually adds a bit of complexity upfront but saves hours of debugging edge cases down the line.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thanks! I agree that a request queue can make sense once you have more complex mutation flows to coordinate. In this case, though, I deliberately kept the fix smaller: serialize the mutation per task on the client so the same interaction can't fire overlapping toggles.

I also considered the library angle, but I wanted to understand the race itself before reaching for a higher-level abstraction. React Query can certainly help with mutation state and cache invalidation, but it doesn't remove the need to think about what the mutation actually means when requests overlap.

For this particular toggle, preventing the second request from starting was enough to fix the race I was debugging. Once retries, multiple clients, or other writers enter the picture, I'd move the correctness guarantee to the server rather than relying on the client queue alone.

Collapse
 
publiflow profile image
PubliFlow

Serializing the mutation per task is a pragmatic approach that keeps the footprint small while still solving the immediate overlap issue. Fully grasping the mechanics of a race condition before reaching for an abstraction is a solid philosophy that prevents black-box debugging later. Have you found that this client-side serialization introduces any noticeable perceived lag when the network is particularly slow?

Thread Thread
 
shubhradev profile image
Shubhra Pokhariya

That's a good question. The initial interaction shouldn't show perceived lag, since the optimistic update happens before the request goes out. The trade-off is that the same task stays disabled until its request settles, so on a slow connection the user has to wait before toggling that task again.

That felt like the right trade-off for this particular case. The rest of the list stays interactive because the pending state is scoped to the task id, so I'm only serializing the interaction that's actually at risk of overlapping.

If the product needed repeated actions on the same item while a request was still in flight, I'd probably revisit the mutation model rather than simply remove the guard. An explicit setComplete(true/false) or server-side concurrency handling can make more sense there than allowing multiple toggle requests to race.

Thread Thread
 
publiflow profile image
PubliFlow

Disabling just the toggled task while keeping the rest of the list interactive is definitely the sweet spot for maintaining perceived performance. I wonder if you considered adding a subtle visual cue, like a slight opacity shift, to indicate the pending state without making the UI feel completely frozen. Have you found that users naturally accept this delay on slower networks, or did you end up adding a timeout fallback just in case the request hangs entirely?

Thread Thread
 
shubhradev profile image
Shubhra Pokhariya

Yeah, I think the visual cue is a useful addition. The disabled state communicates that the task can't be toggled again, but a subtle pending indicator would make the reason more obvious, especially on a slower connection.

I haven't specifically measured whether users are comfortable with that delay. For a production version, I'd want to watch the interaction in real usage and adjust the UX based on actual response times rather than choosing an arbitrary timeout.

I haven't added a timeout fallback for this pattern. My preference would be to make the pending state visibly distinct and give the mutation a clear error path rather than treating a slow response as a client-side failure. A timeout doesn't mean the server didn't process the mutation, so automatically enabling the control again could just reopen the overlap problem the guard was meant to prevent.

Thread Thread
 
publiflow profile image
PubliFlow

You are spot on about the pending indicator, as a subtle spinner removes the guesswork when network latency spikes. It is also a smart move to rely on real-world telemetry rather than assumptions, since perceived performance often dictates user frustration. Have you considered using a tool like LogRocket to track the exact milliseconds users spend waiting before they attempt that problematic fifth click?

Collapse
 
webdeveloperhyper profile image
Web Developer Hyper

Wow! Great update to your previous post. One of the best things about the DEV Community is getting valuable feedback from other developers that makes us think more. You're a great bug smasher!šŸž

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thank you so much! 😊 I completely agree. One of my favorite parts of writing here is seeing other developers point out angles I hadn't considered or explain something in a clearer way. That kind of discussion always ends up making the original idea stronger.

Collapse
 
merbayerp profile image
Mustafa ERBAY

Nice write-up. One thing I’d be careful about is presenting the per-row disabled state as the fix. It prevents duplicate clicks in that specific UI, but race conditions can still happen through retries, multiple tabs, or other clients. I’d treat it as a UX improvement, while relying on server-side idempotency or optimistic concurrency as the real guarantee.

References:

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thanks! That's a good distinction, and I agree.

The per-row pending state was only meant to stop overlapping requests from the same UI interaction. It wasn't intended to be the complete concurrency story.

For this write-up I was deliberately focused on the client-side race that useOptimistic made easy to miss. Once multiple clients, retries, or other writers enter the picture, I'd still rely on server-side guarantees such as idempotency or concurrency control.

Collapse
 
merbayerp profile image
Mustafa ERBAY

Glad we’re on the same page. I also think optimistic UI becomes much easier to reason about when mutations are designed to be idempotent. Instead of ā€œtoggleā€, APIs like markComplete(true) or versioned updates tend to behave much better under retries, offline sync, and concurrent clients. At that point the optimistic UI is mostly a presentation concern, while correctness is enforced by the server.

Thread Thread
 
shubhradev profile image
Shubhra Pokhariya

Thanks! I like that markComplete(true) example. Making the operation explicit instead of relying on a toggle makes the API much easier to reason about once retries come into play. That's a nice way to think about it.

Collapse
 
_hm profile image
Hussein Mahdi

Good writeup, and the pending-id guard is the right client fix. Worth adding that there's a second layer available on the server side: the vulnerability partly comes from the mutation being a toggle rather than a set. Nice property to have when the UI guard is one refactor away from being bypassed by a keyboard shortcut.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thanks! That's a good point. I deliberately kept the focus on the client-side race because that's where this bug showed up, but I agree that a set-style mutation on the server side is a stronger foundation once there are multiple ways the same action can be triggered.

Collapse
 
raju_dandigam profile image
Raju Dandigam

This is a good example of why "felt instant in the demo" is not evidence that the state model is sound. The fifth-click test forces the real contract into view: whether requests commute, whether updates are idempotent, and what the rollback target actually is.

We have started treating optimistic interactions like distributed systems problems for exactly this reason. The UX pattern is local, but the failure mode is concurrency.

Did you end up serializing mutations per entity, or was the winning fix more about versioning or conflict detection on the server side?

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thanks! I really like the way you framed that. "The UX pattern is local, but the failure mode is concurrency" sums it up really well.

For this example, the winning fix was serializing mutations per entity on the client by keying the pending state to the task id. I was trying to stop overlapping toggles from the same interaction path without locking the entire list.

I didn't explore versioning or conflict detection on the server because that wasn't the problem I was trying to solve in this write-up. The focus here was the client-side race that useOptimistic made easy to miss.

Collapse
 
jkming profile image
jkming

The fifth-click test is such a good way to frame it — demos always click once and wait. We hit something similar with a toggle firing overlapping PATCH requests; serializing them with an abort controller plus making the endpoint idempotent is what finally stopped the UI/server drift for us.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thanks! That was exactly the lesson for me too. It's really easy to stop after one successful click and assume everything's fine. I like the combination you described. Handling the client interaction and the endpoint separately is a solid approach.

Collapse
 
99tools profile image
99Tools

Great read! Never thought about testing optimistic UI with rapid clicks. That's a simple but valuable lesson. Thanks for sharing! šŸ‘

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thanks! That was exactly the lesson I took away too. It looked solid until I stopped testing it like a developer and started testing it like an impatient user. Glad you found it useful!

Collapse
 
hemapriya_kanagala profile image
Hemapriya Kanagala

Shubhra, the "clicked it five times" part made me smile because that's exactly the kind of thing real users do šŸ˜„

It's always those edge cases that show up only when you stop testing the happy path.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thank you so much, Hema! 😊 That's exactly what made this bug so interesting to me. Everything looked perfect until I stopped testing it the way I expected it to work and started testing it the way a real user would. That completely changed how I think about optimistic UI.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.