TanStack Query in front of a slow backend: making ERP-backed React apps feel instant
Your ERP answers in 800 ms and no amount of React tuning changes that. What does: not asking twice. The query-key, staleTime and invalidation patterns we use in front of Odoo.
Every headless build we ship has the same shape: a fast React frontend talking to a backend that is, by frontend standards, slow. An Odoo JSON-RPC call that touches pricing rules and stock takes 300–800 ms on a good day. You can't fix that from the browser — the work is real and it happens server-side.
What you can fix is how often you pay for it. The difference between an ERP-backed app that feels instant and one that spins on every click is almost never the backend. It's whether the frontend asks the same question twice. That's a caching problem, and it's exactly the problem TanStack Query exists to solve — if you use it as a cache with a policy, not as a nicer fetch.
Server state is not client state
The mistake that makes ERP frontends miserable is treating server data like component data: a useState, a useEffect, a fetch, a loading spinner. Do that and every component pays the full backend latency on every mount. Navigate away and back — 800 ms again. Two components need the same product — two requests. The backend didn't get slower; you just asked it the same thing five times.
Server state has different physics. It's shared, it goes stale, it can be refetched in the background, and — crucially — a stale answer shown instantly usually beats a fresh answer shown in 800 ms. TanStack Query is built on that premise: every query result lands in a shared cache keyed by a query key, components reading the same key share one request, and revalidation happens behind data the user is already looking at.
The result on an Odoo backend is dramatic: the first visit to a product costs 500 ms, every subsequent look at it — from the list, the cart, the detail page — costs zero, with a background refetch keeping it honest.
Query keys are an API contract
Everything in TanStack Query — caching, deduplication, invalidation — hangs off the query key. Ad-hoc inline keys (["products", id] here, ["product", id] there) are how you end up with a cache you can't invalidate. We treat keys as a contract and generate them from one factory per domain:
export const productKeys = {
all: ["products"] as const,
lists: () => [...productKeys.all, "list"] as const,
list: (filters: ProductFilters) => [...productKeys.lists(), filters] as const,
details: () => [...productKeys.all, "detail"] as const,
detail: (id: number) => [...productKeys.details(), id] as const,
}Hierarchical keys make invalidation surgical or sweeping, your choice: invalidate productKeys.detail(42) after editing one product, or productKeys.all after a bulk import. Nobody has to remember what shape a key was — the factory is the only place keys exist.
staleTime is a business decision, not a config value
The default staleTime is zero: everything is stale immediately, and every window focus triggers a refetch. In front of a fast API that's a fine default. In front of an ERP it means hammering Odoo with calls whose answers haven't changed since Tuesday.
The fix is to stop treating staleTime as a technical knob and start asking the business question: how old may this answer be before it's wrong? The answers differ wildly per domain, so we set them per domain:
// Product catalogue: changes a few times a week, edited in Odoo.
export const catalogueOptions = { staleTime: 15 * 60 * 1000 }
// Stock levels: shown as an indication, checkout revalidates anyway.
export const stockOptions = { staleTime: 60 * 1000 }
// Cart and order status: the user just acted on it. Always fresh.
export const orderOptions = { staleTime: 0 }That comment on stock levels carries the real lesson: the frontend cache never gets the last word. The badge can say "in stock" from a minute-old cache because the order endpoint re-checks at commit time. Cache for feel, validate at the moment of truth — same reason we put Dragonfly in front of Odoo's catalogue API server-side. The two caches stack: TanStack Query eliminates repeat questions per browser, the server cache makes the remaining ones cheap.
Optimistic updates, only where they're honest
An 800 ms round-trip on "add to cart" is where slow backends hurt most — the user did something and the UI visibly hesitates. Optimistic updates fix the feel: write the expected result into the cache immediately, send the mutation, roll back if it fails.
useMutation({
mutationFn: addToCart,
onMutate: async (item) => {
await queryClient.cancelQueries({ queryKey: cartKeys.current() })
const previous = queryClient.getQueryData(cartKeys.current())
queryClient.setQueryData(cartKeys.current(), (cart) => addLine(cart, item))
return { previous }
},
onError: (_err, _item, ctx) =>
queryClient.setQueryData(cartKeys.current(), ctx?.previous),
onSettled: () =>
queryClient.invalidateQueries({ queryKey: cartKeys.current() }),
})The discipline is in the only where they're honest part. Optimism is a promise to the user, so we reserve it for mutations the backend almost never refuses: cart quantities, favourites, notes. Anything Odoo's business rules can reject — minimum quantities, credit limits, out of stock — gets a pending state instead. A rollback the user actually witnesses costs more trust than 800 ms of honest spinner. (This is also why our API layer maps Odoo's UserError to a clean 422 with a human message — the UI needs to know refused from broken.)
Invalidate broad, render from cache
After a mutation, the tempting move is to hand-patch every cached query the change might affect. Don't. Hand-patching is how caches drift from reality — you'll forget the filtered list view that also contains the product. Our rule: optimistically patch the one query the user is staring at, invalidate everything else by prefix.
queryClient.invalidateQueries({ queryKey: productKeys.lists() })Invalidation doesn't blank the screen — that's the part people miss. Stale data stays rendered while the refetch runs behind it. On a slow backend this is the whole trick: correctness costs a background request, not a loading state.
Prefetch the obvious next click
The last latency you can delete is the one that hasn't happened yet. In an ERP frontend the next click is usually predictable — a user hovering a product row is about to open it:
<Link
href={`/products/${id}`}
onMouseEnter={() =>
queryClient.prefetchQuery({
queryKey: productKeys.detail(id),
queryFn: () => fetchProduct(id),
staleTime: catalogueOptions.staleTime,
})
}
>Hover-to-click is 200–400 ms — often the whole Odoo round-trip. The detail page opens from cache and the app reads as instant against a backend that never got faster. We prefetch page 2 of tables and the first products of a rendered list the same way. (If you're on a router that prefetches loader data on hover out of the box — TanStack Start does — you get this for free; wire the loader through the same query keys.)
What this doesn't fix
Honest limits, because a cache is not a performance strategy on its own:
- First hits are still slow. The first user to open a cold page pays full price. Server-side caching and static generation for catalogue-shaped pages fix that half.
- A slow query is still a slow query. If an Odoo call takes four seconds because a custom module searches an unindexed field, caching it is denial. Fix it at the source — that's Odoo performance tuning, not frontend work.
- Truly live data wants a different tool. A warehouse dashboard that must show stock movements as they happen needs polling intervals or a push channel, not a longer
staleTime.
The rule of thumb
When we take over a custom web application with a "slow" frontend, the backend is usually answering in half a second and being asked forty times per session. Before anyone touches the backend, put a real cache policy in front of it: key factories per domain, staleTime set by asking how old each answer may be, optimistic updates only where the backend can't say no, invalidation by prefix, prefetch on intent.
None of it makes the ERP faster. It makes the ERP's speed mostly irrelevant — which, for the person clicking, is the same thing.
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.