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 (21)
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.)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.
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.
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.
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.
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.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.