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.
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);
}
});
}
<input
type="checkbox"
checked={task.completed}
disabled={pendingId === task.id}
onChange={() => handleToggle(task.id)}
/>
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.
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"
}
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+
revalidateTag("tasks") alone is deprecated and will throw a TypeScript error.
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)
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?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
profileargument: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:(or
updateTag("tasks")in Server Actions when you want immediate read-your-own-writes.)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.
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.
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?
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 multipletogglerequests to race.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?
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.
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?
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!š
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.
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:
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.
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.
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.
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.
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.
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?
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.
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.
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.
Great read! Never thought about testing optimistic UI with rapid clicks. That's a simple but valuable lesson. Thanks for sharing! š
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!
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.
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.