The invalidation tax: where TanStack Query ends and TanStack DB begins
In an internal tool, the same order is on screen in five places at once, and every mutation has to remember all five. What a client-side store fixes that a cache can't — collections, live queries, optimistic writes that roll back on their own, and the working set that has to fit in a browser.
The first screen is always easy. A dispatch board over the ERP, one list, one detail panel, TanStack Query in front of a backend that answers in 800 ms, and it feels instant because you're not asking twice. Everyone is happy, including us.
The tenth screen is where the bill arrives. By then the same order is visible in a list, a detail drawer, a customer's history tab, a "late today" counter in the header and a planning grid — simultaneously, on a monitor a planner has open for eight hours. And the expensive part of that application is no longer fetching. It's keeping five views of one row agreeing with each other after somebody changes it.
We've paid that bill enough times to name it. It's the invalidation tax, and you don't pay it per fetch — you pay it per mutation, times the number of places the entity can be.
Query keys are a cache index, not a data model
This isn't a criticism of TanStack Query; it's the thing Query is honest about being. A query cache is keyed by how you asked — endpoint, filters, page — not by what came back. Order 4182 doesn't live anywhere. Copies of it live in ["orders", "list", { state: "open" }], ["orders", 4182], ["dispatch", "2026-08-31"] and ["customers", 88, "orders"], and none of those keys knows the others exist.
So the knowledge of which views contain an order has to live somewhere, and in practice it ends up in the mutation:
const assign = useMutation({
mutationFn: (input: AssignInput) => api.orders.assign(input),
onSuccess: (_, { orderId, customerId }) => {
queryClient.invalidateQueries({ queryKey: ["orders", orderId] })
queryClient.invalidateQueries({ queryKey: ["orders", "list"] })
queryClient.invalidateQueries({ queryKey: ["dispatch"] })
queryClient.invalidateQueries({ queryKey: ["customers", customerId, "orders"] })
// + whatever the planning grid ends up keying on
},
})Every new screen adds a line to every mutation that could possibly touch what that screen renders. That's the wrong dependency direction — a read is now a reason to edit writes — and it fails in three ways we can predict before opening the code:
Too broad. Invalidate ["orders"] wholesale and one assignment triggers six refetches against an ERP that takes most of a second per call. On a busy morning with a planner clicking through a queue, the "instant" board is generating more backend load than the polling loop it replaced.
Too narrow. Somebody adds the planning grid in sprint nine and doesn't update the four mutations that can change what it shows. Nothing breaks loudly. A planner sees a slot that was reassigned twenty minutes ago, calls the technician who isn't coming, and nobody files a bug because "the screen was probably just old."
Hand-patched. The disciplined middle path — setQueryData on the row, targeted invalidateQueries on the lists — is the one we actually recommend for small apps, and it's the one that rots. It's four hand-maintained copies of a merge rule, and the day someone adds a computed field to the list endpoint but not to the patch, one view starts disagreeing with another in a way that only shows up under a specific click order.
None of these are bugs in the sense of a wrong line. They're the shape of the problem: the client has an entity graph, and you've been storing it in a cache indexed by request.
What TanStack DB actually is
TanStack DB is not a faster cache in front of the same idea. It's a client-side store: data is normalized into collections, and every view is a live query over those collections, recomputed incrementally when the underlying rows change.
That single sentence is the entire argument. Order 4182 lives in one place. The five views are five queries that happen to include it. When it changes, consistency isn't something a mutation maintains — it's derived. There is nothing to invalidate, because nothing holds a second copy.
You don't throw away the fetching you already have. A query collection wraps the loader you already wrote:
import { DbClient, collectionOptions } from "@tanstack/db"
import { queryCollectionOptions } from "@tanstack/query-db-collection"
import { QueryClient } from "@tanstack/query-core"
const queryClient = new QueryClient()
export const db = new DbClient({ queryClient })
export const ordersCollection = collectionOptions("orders", (client) =>
queryCollectionOptions({
id: "orders",
queryKey: ["orders", "open"],
queryClient: client.requireDependency<QueryClient>("queryClient"),
queryFn: () => api.orders.list({ state: "open" }),
getKey: (order) => order.id,
schema: orderSchema,
})
)Same endpoint, same NestJS layer in front of the ERP, same Zod schema. What changed is that the result is no longer "the answer to a question" — it's a table.
The join moves to the client, and that's the point
Once orders and customers and technicians are collections, the screen-shaped question gets asked on the client:
import { useLiveQuery } from "@tanstack/react-db"
import { and, eq } from "@tanstack/db"
const { data: late } = useLiveQuery({
query: (q) =>
q
.from({ order: ordersCollection })
.join(
{ customer: customersCollection },
({ order, customer }) => eq(order.customerId, customer.id),
"inner"
)
.where(({ order }) => and(eq(order.state, "open"), eq(order.dueToday, true)))
.orderBy(({ order }) => order.promisedAt, "asc")
.select(({ order, customer }) => ({
id: order.id,
promisedAt: order.promisedAt,
customer: customer.name,
technician: order.assignedTo,
})),
})For an app sitting on Odoo, the second-order effect is bigger than the ergonomics. Every screen-specific endpoint you don't need is a controller you don't write, don't secure, don't version and don't maintain — and on the ERP side those are not cheap. Anyone who has built out an external API surface on Odoo knows the drift: /dispatch/late-today exists because a screen needed it in 2024, it's still deployed, and nobody is sure which client calls it. Three or four honest collection endpoints that return entities, plus joins in the browser, is a smaller surface to own.
The limit is real and you should hit it deliberately rather than in production: this is a client-side store, so the working set has to fit in a browser. A few thousand rows per collection is comfortable and fast. The full sales history of a wholesaler is not a collection — it's a report, and reports stay server-side where the data is. Scope collections to what one shift actually touches: open orders, today's routes, active customers. If you find yourself paginating a collection, you've picked the wrong tool for that screen.
Optimistic writes, and why the rollback is the feature
Writes go through the collection, and the collection tells your server:
export const ordersCollection = collectionOptions("orders", (client) =>
queryCollectionOptions({
// ...
onUpdate: async ({ transaction }) => {
const { original, changes } = transaction.mutations[0]
await api.orders.update(original.id, changes)
},
})
)orders.update(order.id, (draft) => {
draft.assignedTo = technicianId
})The optimistic state is applied immediately as an overlay on the synced data, the handler runs, and if it throws, the overlay is dropped. All five views move together, and they move back together.
That last part is the reason we care. We've written before that optimistic updates are only worth doing where they're honest — where you can predict the server's answer. With an ERP behind the API, you frequently can't. A record rule says this planner may not assign that team. A constraint in a custom module refuses the state transition. A sequence has run out. The write fails for reasons the browser had no way to know, and the interface has to retract a promise it already made to a human.
In Query, that retraction is onMutate snapshotting, onError restoring and onSettled invalidating — written per mutation, correct in the one you reviewed carefully, approximate in the other eleven. In DB it's structural: throw, and the overlay goes. That's not a convenience. It's the difference between rollback being a thing each developer remembers and rollback being a property of the system.
One discipline it demands, which is the failure everybody hits once: the handler must not resolve before the change is readable back. If onUpdate returns the moment the ERP accepts the write, the optimistic overlay drops while the collection is still holding the old row — and the row visibly snaps back to its previous value for a frame or two before the refetch lands. It looks like the app undid the user's action, which is a support call. Await the refetch, or await the transaction id the sync layer is waiting for, and the handoff is seamless.
The mirror-image mistake is a handler that resolves too late — a refetch() of a 4,000-row collection after every keystroke-sized edit. Small writes should be confirmed by the write's own response, not by re-reading the world.
Live still means live: DB is not a sync engine
A query collection refetches. That's all. Putting your data in collections does not make it update when a colleague changes something — it makes it consistent when it updates, which is a different property and the one worth paying for first.
If the app needs to be live, the event still has to come from somewhere. On the stacks we run, that's usually a Postgres trigger and one SSE stream, and it lands much more cleanly here than it did in a query cache. Instead of guessing which keys to patch, you write the row into the collection:
source.onmessage = (event) => {
const change = JSON.parse(event.data) as OrderChange
orders.utils.writeUpdate({ id: change.orderId, state: change.state })
}One write, every view that includes that order re-renders, and nothing else does. The change log and replay-on-reconnect design from that post is unchanged — it's still a doorbell, the truth is still in a table you can re-read.
The other option is a real sync engine — Electric, PowerSync — and here we have to be honest about the shape of our own projects. Those want to stream from the Postgres that is the source of truth. When the source of truth is Odoo, deliberately not on the internet and reached through an API layer, streaming its tables to browsers is not a library choice; it's an architecture change, with row-level authorisation to redesign. We've used Electric where the app owns its own Postgres and the ERP is a downstream integration. We have not put it in front of a client's live ERP database, and we'd want a very specific reason to.
You don't migrate the app, you migrate an entity
The reason we're comfortable proposing this mid-project is that it isn't a rewrite, and the sequencing matters enough to spell out. Both libraries run side by side, in the same tree, with the same QueryClient.
Pick the entity that appears in the most places — it's almost always the one the business is actually about: the order, the ticket, the shipment. Give it a collection, then convert the screens that render it, one at a time, deleting an invalidateQueries line from every mutation as its last reader moves over. Everything you haven't touched keeps working, because it's still ordinary Query against the same endpoints.
Two things make that go badly, and both are avoidable. Don't leave one screen behind. A single view still reading through the old query key is a copy of the row that nobody updates any more, and it's now stale in a way that's harder to spot than before, because the four screens next to it are right. Convert an entity's readers completely or don't start on that entity. And don't start with the hardest screen. The planning grid with the drag-and-drop and the eleven derived counters is the one that justified the whole exercise, and it's the worst place to learn the query builder. Start with a boring list, get the write path and the rollback correct there, then move the screen you're doing this for.
Two entities in, the pattern usually stops needing an advocate: the pull request that adds a screen touches no mutation code at all, and everyone notices.
What it costs
It is not free, and the honest list is short but real.
A second mental model in the same app. Query stays — for the one-shot reads, the mutations that aren't entity edits, everything on the marketing side. Now the team has to know when a thing is a collection and when it's a query, and new developers get that wrong for a few weeks.
Schema definition, twice-ish. Collections want a schema to validate what comes in. If your API layer already owns the Zod contract you can share it, and you should. If it doesn't, you've just created a second place where the shape of an order is written down, and the two will drift the first time someone adds a field on the backend only.
Debugging shifts. A wrong list in Query is one key and one network tab entry. A wrong list here is a query over collections whose contents came from somewhere else — you debug the pipeline, not the request. It's not harder, but the reflexes are different, and the first hour of the first bug feels like a step backwards.
Bundle and startup. Loading three collections up front to make joins possible means the first paint waits for more data than a single screen needed. For an internal tool on office wifi, opened once and left open all day, that trade is obviously right. For a page a customer lands on from Google, it is obviously wrong.
The upside worth naming: because the store knows what's confirmed and what isn't, the interface can say so. Every row carries $synced and $origin, so "saved" and "saving" stop being a separate piece of component state you thread through props — a pending row can render at 60% opacity because it is pending, not because a boolean somewhere says it might be.
Where we'd still just use Query — or nothing at all
We reach for TanStack DB when a specific set of things are all true: it's an internal tool or an operator interface, the same entities appear on several screens at once, users write to them, and the session is long. Dispatch, planning, warehouse, helpdesk queues, back-office review screens — the applications where somebody has the tab open from 8:00 to 17:00.
We don't, when:
- The page is a page. Read, act, navigate away. A Server Component fetching on the server beats every client store, because the best client cache is the one you never ship.
- It's public and mostly read. Storefronts, marketing, anything indexed. Static-first still wins there, and shipping a normalized store to an anonymous visitor is pure cost.
- There's one screen. Query with sensible
staleTimetiers is less code and less to explain to whoever maintains it in three years. Don't buy consistency machinery for a system that has nothing to keep consistent. - The data is genuinely big. If the answer to "how many rows" is "we'd have to check", it's a server-side query with pagination, not a collection.
The rule of thumb
Count the places one entity can be on screen at the same time, in a session where users also change it. At one, use Query. At two, use Query and write the patches carefully. At three, you are already paying the invalidation tax — you're just paying it in mutation code, staleness bugs and a refetch storm nobody has profiled yet, instead of in a dependency.
The question was never "which data-fetching library". It's whether your client has a data model or an index of past requests. For the custom applications we build on top of an ERP, the ones people work in all day, it needs a data model — and it's cheaper to adopt one than to keep hand-maintaining the illusion of one.
Want us to publish something specific?
Tell us what you'd like to read and we'll add it to our writing queue.
Get the next one in your inbox
New articles, videos and the occasional engineering note — a short mail when there’s something worth reading, nothing else.