There's a moment in almost every web application where you do something — click a button, update a field, save a form — and the UI flickers. The content disappears for a fraction of a second, a loading skeleton appears, and then the data comes back. It happens so fast you might not consciously register it. But your brain does. It feels janky. It feels untrustworthy. It feels like the software isn't quite sure what it's doing.
We spent weeks hunting down and eliminating every single data flicker in SalesSheet. Here's the three-layer architecture we built to make it happen, and the specific email flicker bug that taught us the most.
Why Flicker Happens
Flicker is a symptom of a common pattern in frontend development: invalidate-and-refetch. When you update data, the app invalidates its cache, shows a loading state, fetches the fresh data from the server, and re-renders. During that fetch window — anywhere from 50ms to 500ms — the user sees either a loading skeleton or stale data disappearing and reappearing.
This pattern is the default behavior in many data-fetching libraries. It's the "safe" approach because you're always showing server-confirmed data. But it sacrifices user experience for data consistency in a way that's unnecessary for most operations.
The fix isn't to skip the server call. You still need data consistency. The fix is to layer multiple strategies so the user never sees a gap.
Layer 1: Optimistic Update
The first layer is the most impactful. When a user performs an action — updating a contact's email, moving a deal to a new stage, logging an activity — we immediately update the local cache with the expected result. The UI reflects the change at t=0, before the server even receives the request.
This is called an optimistic update because we're optimistically assuming the server will accept the change. For the vast majority of operations in a CRM (editing fields, creating records, updating statuses), the server almost always accepts. The rare failure case is handled by rolling back the optimistic change and showing an error notification.
Optimistic updates eliminate the most visible source of flicker: the gap between user action and server response. Instead of action → loading → result, the user sees action → result. The perceived latency drops to zero.
Layer 2: Background Refetch With Diff/Merge
The optimistic update shows the user what they expect to see. But we still need to confirm with the server and get any data that might have changed server-side (timestamps, computed fields, data from other users). This is where the second layer comes in.
After the optimistic update, we fire a background refetch to the server. When the response arrives, we don't just replace the entire cache entry — that would cause a re-render and potentially a flicker. Instead, we run a diff between the optimistic data and the server data. Only fields that actually changed get updated in the cache.
This diff/merge step is critical. Without it, even a background refetch can cause subtle flicker. If the server returns the same email address with slightly different casing, or includes a whitespace difference in a field, a naive replacement would trigger a re-render. The diff catches these false positives and suppresses unnecessary updates.
Layer 3: Stale Fallback
The third layer handles the worst case: what happens if the background refetch fails? Network timeout, server error, offline status. In a traditional app, you'd show an error state or a loading skeleton. Both are flicker events.
Instead, we keep showing the last-known-good data with a subtle stale indicator — a small badge or timestamp that tells the user the data might not be current. The visual content stays stable. There's no jarring transition from "data" to "no data" to "data again."
This stale fallback is the safety net. It ensures that no matter what happens with the network or server, the user always sees coherent content. The worst case is slightly outdated data, not an empty screen.
The Email Flicker Bug
The hardest flicker to fix was in the email display. When a user viewed a contact's email history, the list would briefly flash to empty and back every time they navigated away and returned. The issue was subtle and took days to track down.
The root cause was our data normalization layer. Email messages were stored as nested objects within the contact record. When we invalidated the contact cache, the email sub-collection was also invalidated. The contact data refetched quickly, but the email list had a separate query that lagged behind by 100-200ms. During that gap, the UI showed the contact details (from the fast query) with an empty email list (from the pending slow query).
The fix was to apply the same diff/merge pattern at the email level. Instead of replacing the entire email list on refetch, we diff the old and new lists, merge additions, and preserve the existing rendered items. The email list never empties; new messages appear seamlessly alongside the existing ones.
The Compound Effect of Zero Flicker
Individually, each flicker is tiny — a 100ms visual disruption. But cumulatively, they destroy the feeling of quality. A user who sees 20 micro-flickers per session subconsciously registers the software as unstable, even if every operation succeeds.
Eliminating flicker has a compound effect on perceived performance. When the UI never stutters, users trust it more. They move faster because they don't hesitate before clicking. They spend less time watching loading states and more time doing their actual work — which is exactly what a chat-first CRM should enable.
The same philosophy drives our approach to security and analytics: get the fundamentals right, and the user experience follows. Flicker elimination isn't a flashy feature. You'll never see it on a marketing page. But it's the kind of engineering discipline that separates tools people tolerate from tools people love.
Implementation Tips
If you're building a data-heavy application and want to eliminate flicker, here are the practical takeaways:
- Default to optimistic. Any write operation where the server acceptance rate is above 95% should be optimistic. That covers most CRUD operations in a typical app.
- Always diff before updating cache. Never blindly replace cached data with server data. Diff first, merge only changes. This prevents false-positive re-renders from insignificant data differences.
- Use stale-while-revalidate, but show it. Stale data is better than no data. Add a subtle indicator so users know the data might be outdated, but never show an empty state for data that was just visible.
- Test with network throttling. Flicker bugs are invisible on fast connections. Throttle your network to 3G in browser dev tools and use the app normally. Every flicker becomes obvious.
- Watch for nested invalidation. The email bug taught us that nested data structures need independent caching strategies. Invalidating a parent should not automatically invalidate all children.
Zero flicker isn't a nice-to-have. For any application where users interact with data repeatedly throughout the day, it's the foundation of a professional user experience.
Experience Zero-Flicker CRM
Every interaction feels instant. No loading skeletons, no data flashing, no jank.
Try SalesSheet Free — No Credit Card