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.
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);
}
});
}
<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, 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.
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, 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+
revalidateTag("tasks") alone is deprecated and will throw a TypeScript error.
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>
);
}
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)
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 😄
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!
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.
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.
Scoping the
pendingIdlock 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.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.
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.
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.
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.
the
unstable_retryvsresetdistinction is the one that bites first. we spent a sprint on a broken error screen — it wasreset, which just rerenders without a server round trip. took us too long to figure outresetandsettleare not the same thing.the place it matters: data fetch failures behind a stale ISR cache.
resetsurfaces the cached version.unstable_retryforces a fresh render. for a task checkbox fine either way. for a shared dashboard with a 5 minute cache, completely different call.was the
revalidateTagsecond argument a 16.1 addition or was it there earlier?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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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