Hasan's Journal

Stories, lessons, and scars from production.

Mehedi Hasan
Back to blog

A No-Nonsense Guide to RTK Query in Enterprise Apps

Tag-based invalidation, optimistic updates, and the patterns that hold up under real production load — not the toy examples from the docs.

#Redux#RTK Query#Performance

Why RTK Query?

RTK Query replaces hand-rolled thunks, scattered loading flags, and manually written cache invalidation with a single declarative API. You define an endpoint once, describing the request and how it relates to other cached data, and RTK Query handles fetching, caching, loading states, and background refetching automatically. The reduction in code volume is substantial: a typical endpoint definition is 10 to 15 lines, compared to a hand-rolled slice with thunks and selectors that would run to 80 or more. The reason to choose RTK Query over TanStack Query is mostly about ecosystem fit — if you're already using Redux Toolkit for global state, RTK Query integrates with the same store, devtools, and middleware pipeline.

RTK Query shines in applications with complex mutation patterns, because the tag-based invalidation system lets you express "this mutation invalidates these queries" as a declarative relationship rather than imperative cache updates. Once you've tagged your queries and mutations correctly, the invalidation logic is automatic — you don't have to remember to refetch the orders list after creating an order, because the system knows the orders list depends on the "Orders" tag and the create-order mutation invalidates that tag. That automation is valuable in a team setting, where forgetting to invalidate one of several dependent queries is the kind of bug that surfaces only after deployment.

Cache Invalidation Strategy

We rely heavily on tag-based invalidation. Every query is tagged with the type of data it returns, and every mutation declares which tags it invalidates. When a mutation runs — creating a new order, for example — it invalidates the "Orders" tag, which automatically triggers a refetch of any currently-mounted query that depends on that tag. This keeps the UI consistent across the whole application without any component needing to manually know about every other component displaying the same data. The hardest part is modeling the tag taxonomy correctly: tags too coarse cause over-invalidation (every mutation invalidates everything); tags too fine cause under-invalidation (mutations don't invalidate queries they should, and the UI shows stale data).

Most applications end up with a two-level tag taxonomy: a coarse tag for the entity type and a fine-grained tag for individual entities, and mutations invalidate both. A subtler issue is handling invalidation for mutations that succeed but produce a partial result — if a "create order" mutation returns the created order, you can use that to surgically update the cache rather than refetching the whole list, which is faster and avoids a loading flicker. The pattern uses `onQueryStarted` to optimistically add the new order to the cached list and either keep the update or roll it back depending on whether the mutation succeeds.

Optimistic Updates

In a factory setting where slow networks are the norm, optimistic updates make a meaningful difference in perceived responsiveness. Using `onQueryStarted`, we immediately update the local cache with the expected result before the server confirms, and roll back automatically if the server rejects. The user sees their action take effect instantly and is never left waiting for a network round-trip to confirm something they have high confidence will succeed. Optimistic updates are easy to get wrong, though: the most common mistake is applying the update without saving a snapshot of the previous state, which makes rollback impossible. The second is applying the update inconsistently with how the server will eventually apply it, causing UI flicker.

Keeping rollback logic simple was deliberate: store a snapshot of the previous cache state before applying the optimistic update, and restore that exact snapshot on error, rather than trying to compute a clever reversal. The "store and restore" pattern works for any optimistic update, no matter how complex, and doesn't require the rollback code to understand the semantics of the update — it just restores a known-good state. The "compute the reversal" pattern requires the rollback code to be the inverse of the update code, which means the two have to be kept in sync manually, and any inconsistency between them produces subtle bugs that surface only on rollback.