← Back to list

Part 1: The Vanishing UI Caused by Polling — A Design Bug Born from Depending on Filtered Cache

Introduction

Hayato Taira · 2026-04-09 12:40 · 0 claps · 7.8 min read
#front-end-development #tanstack-query #rtk-query #web-development
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Part 1: The Vanishing UI Caused by Polling — A Design Bug Born from Depending on Filtered Cache

Introduction

This article documents the investigation, root cause analysis, and solution selection for a bug in a frontend project with chat functionality, where messages would disappear after a status change.

To state the conclusion upfront: the root cause was a design issue where the detail view derived its data from the filtered list cache. The moment a conversation’s status changed, it fell outside the filter criteria and was removed from the cache, causing the detail view to lose its data. The chosen solution adopts TanStack Query’s optimistic update pattern to prevent the cache from ever becoming empty, even momentarily.

With a reproduction rate of roughly 40%, multiple API protocols coexisting in the system, and the need to verify TanStack Query’s internal behavior, this turned out to be a debugging experience that cut across considerable technical complexity.

1. Problem Overview

Symptoms

After pressing the status change button on the chat screen, the thread’s messages (body text and action buttons) would sometimes disappear.

  • Reproduction rate: approximately 40%
  • Once disappeared, messages never recovered automatically

Screen Layout

+-- Chat Screen ---------------------+
|                                    |
| +-- List ---+  +-- Detail -------+ |
| | > Thread A|  | Message body... | |
| |   Thread B|  | [Action] [Skip] | |
| +-----------+  +-----------------+ |
+------------------------------------+

2. Understanding the System Architecture (Investigation Phase)

Coexistence of gRPC and ConnectRPC

This system has two API protocols running side by side.

gRPC PathConnectRPC PathTarget usersFeature flag OFFFeature flag ONData fetching libraryRTK Query (Redux)TanStack Query (React Query)Cache managementRedux StoreReact Query cache

This bug occurs only on the ConnectRPC path. The important point is not the difference in technology stacks per se, but that the data fetching designs are fundamentally different.

  • gRPC path (RTK Query): The detail view fetches its data through an independent API call. It does not depend on the list cache state.
  • ConnectRPC path (TanStack Query): The detail view derives its data by scanning the entire list cache. If the list cache changes, the detail view breaks.

In other words, the ConnectRPC path breaks not because of the protocol, but because of the design decision to derive detail data from the list cache.

Data Flow

Left panel (list):
  useConversationList
    └── useFilteredConversations({ state: 'ACTIVE' })
          └── React Query cache
                Key: ['conversation', 'filteredList', { state: 'ACTIVE' }]
                Polled at regular intervals

Right panel (detail):
  useThreadMessages
    ├── useConversationDetail  ← initial message
    │     └── Scans entire cache via queryClient.getQueryCache().findAll()
    │           Searches for conv-123 from filteredList(ACTIVE)
    └── useRepliesData         ← reply messages

The Design Problem: Implicit Dependency via Full Cache Scan

The detail view’s initial message retrieval depends on the list cache (filtered by ACTIVE). This is the root cause of the bug.

Specifically, the implementation uses queryClient.getQueryCache().findAll() to directly scan the cache and locate the target data. This design has the following problems:

  • Query key scoping breaks down: TanStack Query’s cache is designed so that each query key is independent, but scanning the entire cache creates an implicit dependency between otherwise unrelated queries.
  • Data survival depends on filter criteria: Simply changing the list’s filter criteria (state: 'ACTIVE') causes the detail view's data to vanish. The detail view only needs "the data for this thread," yet its fate is tied to the list's search conditions.
  • Cache becomes the source of truth for the domain: Cache is meant to be a temporary copy of server data. When other components reference the cache directly as a data source, the presence or absence of cached data determines application correctness.

3. Bug Occurrence Mechanism

Timeline

T=0    Cache:
       filteredList(ACTIVE) = [conv-123, conv-456]
       Detail view: finds conv-123 → displaying messages ✅

T=1    Status change button pressed
       Backend changes conv-123 to DONE

T=N    Polling fires
       GET /conversations?state=ACTIVE
       Response: [conv-456]  ← conv-123 is no longer ACTIVE, so it's excluded
       Cache updated: filteredList(ACTIVE) = [conv-456]

T=N+   Detail view re-renders
       useConversationDetail: scans entire cache
       → conv-123 not found ❌
       → conversation = null
       → Initial message (body text, action buttons) disappears
       → invalidateQueries fires, but only re-fetches the ACTIVE filter, so recovery never happens

Why the Reproduction Rate Is Approximately 40%

Reproduction is unstable because it depends on polling timing.

  • If polling fires immediately after the button press, the bug occurs.
  • If there is time before the next poll and other UI updates happen first, the bug does not manifest.

4. Exploring and Evaluating Solution Approaches

Initial Three Approaches (Investigation Phase)

Approach 1Approach 2Approach 3MethodIndividual fetch API fallbackAdd unfiltered cachePreserve previous value with useRefDifficultyMediumHighLowRecommendation★★★★★★

Initially, Approach 1 (individual fetch API) was the recommended option, but it was confirmed that the backend had no API for fetching individual records, eliminating this option.

Explored Approaches and Reasons for Rejection

(1) Consolidating to a Single Message Fetch API

This approach would consolidate everything into the existing message list hook (which uses the ConnectRPC message fetch API).

Reason for rejection: The detail hook’s return type (Conversation object) differs from the message list hook's return type (ReplyEntity[]), requiring a large-scale refactoring of the detail view's rendering logic as well. This was deemed infeasible within a realistic time budget.

(2) Preserving the Previous Value with useRef

In this approach, when conversation becomes null inside the detail hook, the previously saved value from useRef would be returned instead.

Reason for rejection: useRef is only effective within a single component's lifecycle for one threadId.

1. Complete conv-A → save conv-A's data in ref
2. Switch to conv-B → threadId changes → ref resets for conv-B
3. Return to conv-A → ref is empty, conv-A is also gone from cache
→ Messages disappear ❌

This breaks down in use cases where multiple conversations are completed within a single polling interval.

(3) Constant Polling of filteredList { state: ‘DONE’ } + Optimistic Update

This approach would constantly poll a DONE query, and on status change button press, optimistically update via setQueryData to remove the conversation from the ACTIVE cache and add it to the DONE cache.

Discovered problem: Optimistic writes via setQueryData get overwritten by subsequent polling.

T=0  setQueryData → add conv-123 to DONE cache

T=2  Polling fires (backend not yet confirmed)
     → DONE cache overwritten → conv-123 disappears ❌

T=3  Backend succeeds
     But the gap between T=2 and T=3 exists

The following approaches were considered to address this problem.

(4) Module-Level Map as Fallback

This approach places a module-level Map<string, Conversation> outside the React Query cache and saves a snapshot when the status change button is pressed. On cache miss in the detail hook, the Map serves as a fallback.

const completedStore = new Map<string, Conversation>();

// On button press
completedStore.set(threadId, currentConversation);
// Inside the detail hook
const conversation =
  findInReactQueryCache(threadId) ??
  completedStore.get(threadId);

Characteristic: Polling can only overwrite the cache, so the Map data remains safe.

Reason for rejection: This diverges from TanStack Query’s official optimistic update pattern. Additionally, transformations applied via select are not reflected in raw cache scans like queryClient.getQueryCache().findAll(), requiring extra workarounds.

(5) Pausing Polling with refetchInterval: false

This approach would set refetchInterval: false only during mutations to temporarily stop polling.

Reason for rejection:

  • The isMutating state needs to be shared across the component tree (the reply button component and the list fetch hook reside in different subtrees).
  • The reply submission mutation uses RTK Query, making await impossible with the current design.
  • Adding state to Redux would increase the number of changed files.

5. The Technical Crux: Does cancelQueries Reset the Timer?

To confirm the correctness of the final solution (described below), a key question was: “When cancelQueries is called, does it reset the refetchInterval timer?"

The Question and Conclusion

TanStack Query’s official optimistic update pattern calls cancelQueries inside onMutate. It is clear that this cancels in-flight requests, but when the next poll (refetchInterval) fires afterward is not explicitly documented.

If the timer is not reset, polling could fire immediately after cancelQueries and overwrite the optimistic update.

Conclusion: The timer is reset. This was confirmed by reading the source code. Two conditions must be met:

ConditionDescriptionThis casehasListeners() is trueA component subscribing to the query must existThe list fetch hook is mounted ✅silent: true is not passedThe silent option must not be set on cancelQueriesNot passed in the standard optimistic update pattern ✅

Furthermore, setQueryData also triggers a timer reset. TanStack Query maintainer TkDodo stated in GitHub Discussion #5132: "if new data winds up in the cache, it resets the interval. That is on purpose." In other words, in the optimistic update pattern, both cancelQueries and setQueryData contribute to the timer reset, providing a double safeguard against polling interruption.

Supplementary: Source Code Verification Path

The following path was traced by directly reading node_modules/@tanstack/query-core/src:

cancelQueries() → query.cancel({})
  → Creates CancelledError({ silent: undefined })
Inside fetch()'s catch block (query.ts):
  Since silent is falsy, dispatch({ type: 'error' }) is executed
observer.onQueryUpdate() (queryObserver.ts):
  → updateResult()
  → If hasListeners() is true, calls updateTimers()
updateTimers() → updateRefetchInterval():
  → clearRefetchInterval()   // Destroys the existing timer
  → setInterval(..., interval)  // Restarts from 0

6. Final Solution

Why Optimistic Updates Solve This

The essence of this bug is that there exists a moment when the cache becomes empty. When polling fetches the latest state from the server, the conversation whose status has changed is excluded by the filter and removed from the cache. If the detail view re-renders at that exact moment, there is no data to reference.

Optimistic updates rewrite the client-side cache immediately without waiting for the server response. This ensures the UI always holds data, preventing the asynchronous timing gap between client and server from breaking the UI.

Strategy

Adopt TanStack Query’s officially recommended optimistic update pattern.

Reference: https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates

Since the reply submission mutation uses RTK Query, the useMutation hooks (onMutate/onError/onSettled) cannot be used directly. However, queryClient methods can be called from anywhere, so the same pattern can be achieved by writing equivalent logic in the event handler.

Operation Flow

① User presses the status change button

② onMutate equivalent (inside the button handler)
     cancelQueries: Cancel in-flight requests for ACTIVE / DONE
                    → refetchInterval timer resets from 0
     setQueryData:  filteredList(ACTIVE)      → remove conv-123
                    filteredList(DONE) → add conv-123
                    → This also contributes to timer reset

③ Send reply submission mutation to the backend (await)

④ onError equivalent (on failure)
     Rollback via setQueryData
     filteredList(ACTIVE)      → restore conv-123
     filteredList(DONE) → remove conv-123

⑤ onSettled equivalent (regardless of success or failure)
     invalidateQueries: invalidate ACTIVE / DONE
     → Immediately update with confirmed data from the backend

Zero-Gap Guarantee

T=0    cancelQueries → Timer reset (restarts from 0)
        setQueryData  → Add conv-123 to DONE cache ✅
                        (setQueryData also contributes to timer reset)

T=3    Backend succeeds → invalidateQueries → Immediately update with confirmed data ✅

T=N    Next poll (backend already confirmed) ✅
(Because both cancelQueries and setQueryData reset the timer,
  no polling can interrupt between T=0 and T=3)

Summary of Changes

ChangeDescriptionChange 1Reply button component: Implement optimistic updates (cancelQueries / setQueryData / rollback / invalidateQueries)Change 2List fetch hook: Add constant polling for the DONE query

7. Lessons Learned During the Exploration

Verify cancelQueries Behavior by Reading the Source Code

cancelQueries is commonly understood as "cancels in-flight requests," but it actually has additional side effects. Until reading the source code, the refetchInterval timer reset behavior was not explicitly documented, making it impossible to state with certainty how it would behave.

A surface-level understanding of frontend library behavior is insufficient. For critical behaviors, reading the actual source code is necessary.

The Limits of useRef’s Applicability

useRef is only effective within “a single component’s single lifecycle.” It breaks down in use cases that span multiple conversations. Without clearly defining “at what scope should state be held” during design, the result is an implementation that appears to work but breaks under specific conditions.

Separating Cache “Read Side” and “Write Side”

TanStack Query’s select option transforms "the return value to the component," but this transformation is not reflected in raw cache scans like queryClient.getQueryCache().findAll(). The distinction between the cache's "externally visible shape" and its "internal raw data" needs to be considered during design.

Conclusion (Part 1)

This article documented the process from uncovering the bug’s occurrence mechanism, through exploring and evaluating solution approaches, to verifying TanStack Query’s internal behavior.

Part 2 will cover the actual implementation code and verification results.


메타데이터
post_id
ebc8cd404a34
slug
part-1-the-vanishing-ui-caused-by-polling-a-design-bug-born-from-depending-on-filtered-cache-ebc8cd404a34
url
https://medium.com/@hayato.y/part-1-the-vanishing-ui-caused-by-polling-a-design-bug-born-from-depending-on-filtered-cache-ebc8cd404a34
canonical_url
https://medium.com/@hayato.y/part-1-the-vanishing-ui-caused-by-polling-a-design-bug-born-from-depending-on-filtered-cache-ebc8cd404a34
author_url
https://medium.com/@hayato.y
status
ok
fetched_at
2026-07-16 17:31:52