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 Sma...
For further actions, you may consider blocking this person and/or reporting abuse
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.
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.