On this page · sections
Server State & Data Fetching
TanStack Query v5 separates freshness (staleTime) from memory retention (gcTime). A trading dashboard combines websocket tick streams, throttled UI updates, and React Query for REST — layered by update frequency, not one state tree.
| Knob / Layer | Controls |
|---|---|
staleTime | How long data is “fresh” — no auto-refetch while fresh |
gcTime | How long inactive query data stays in memory after last observer unmounts |
| Market tape | 100+/sec ingest, 4–10/sec display — Zustand normalized store + selectors |
| Positions / orders | Seconds to minutes — React Query REST + event invalidation |
| Order entry | On submit — optimistic mutation + rollback |
staleTime vs gcTime
Definition
'use client';
function Product({ id }: { id: string }) {
const { data, isFetching } = useQuery({
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
staleTime: 60_000, // fresh 1 min — no refetch on remount within minute
gcTime: 10 * 60_000, // keep cache 10 min after unmount
});
return <div>{data?.name} {isFetching && '(updating…)'}</div>;
}Fetch success
→ data fresh for staleTime
→ staleTime elapses → stale (still in cache)
→ refetch triggers may run in background
→ all observers unmount → gcTime countdown
→ gcTime elapses → garbage collected
Defaults (v5) → staleTime: 0 · gcTime: 5 minutesisFetching = network in flight. isStale = past staleTime — may still display cached data during background refetch. staleTime: Infinity → manual invalidation only.
Interview: v5 renamed
cacheTime→gcTimeto clarify garbage collection of inactive queries, not freshness.
Refetch triggers
Definition
Refetch when query is stale AND a trigger fires:
mount · window focus · network reconnect · interval · manual refetch()const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: 2,
},
},
});
function Orders() {
const { data, isFetching } = useQuery({
queryKey: ['orders'],
queryFn: fetchOrders,
refetchInterval: 60_000,
refetchIntervalInBackground: false,
});
return <section aria-busy={isFetching}>{/* ... */}</section>;
}Background refetch: show subtle isFetching indicator — not full skeleton. Pagination: placeholderData: (prev) => prev keeps previous page visible while next loads.
Invalidation and key factories
Definition
const orderKeys = {
all: ['orders'] as const,
detail: (id: string) => [...orderKeys.all, id] as const,
};
function useCancelOrder() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => cancelOrder(id),
onSuccess: (_data, id) => {
qc.invalidateQueries({ queryKey: orderKeys.all });
qc.invalidateQueries({ queryKey: orderKeys.detail(id) });
},
});
}['orders'] → list
['orders', 'abc'] → detail
['orders', 'abc', 'lines'] → nested
invalidateQueries({ queryKey: ['orders'] }) → all three become staleOptimistic flow: onMutate snapshot → setQueryData patch → onError restore → onSettled invalidate. App Router: Server Action calls revalidateTag('orders') for RSC; client calls invalidateQueries — both layers must align.
Interview:
setQueryDatawhen you know response shape; invalidate when side effects are unpredictable.
SSR dehydrate/hydrate
Definition
// app/users/page.tsx
export default async function UsersPage() {
const qc = new QueryClient();
await qc.prefetchQuery({ queryKey: ['users'], queryFn: fetchUsersServer });
return (
<HydrationBoundary state={dehydrate(qc)}>
<UsersClient />
</HydrationBoundary>
);
}'use client';
export function UsersClient() {
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsersClient,
staleTime: 60_000, // match server freshness — avoid immediate refetch
});
// ...
}1. Server creates per-request QueryClient (never global — PII leak)
2. prefetchQuery populates cache
3. dehydrate → HydrationBoundary state
4. Client rehydrates — useQuery skips fetch if still freshstaleTime: 0 after SSR prefetch → immediate client refetch — wasteful double fetch.
Cache policy by entity volatility
Definition
Category listing staleTime 5–15 min · gcTime 30 min · no focus refetch
PDP detail staleTime 1–2 min · tag invalidation on CMS publish
Inventory staleTime 0–30s · focus refetch or websocket
Cart staleTime 0 · optimistic mutations · invalidate every action
Search staleTime 0 · short gcTime · debounced query keysexport const cachePolicy = {
catalog: { staleTime: 10 * 60_000, gcTime: 30 * 60_000 },
product: (id: string) => ({
queryKey: ['product', id] as const,
staleTime: 90_000,
gcTime: 15 * 60_000,
}),
inventory: (sku: string) => ({
queryKey: ['inventory', sku] as const,
staleTime: 15_000,
refetchOnWindowFocus: true,
}),
cart: { staleTime: 0, gcTime: 5 * 60_000 },
};CMS webhook must fire both:
revalidateTag(`product-${id}`) → Next Data Cache
invalidateQueries(['product', id]) → React Query clientInterview: Volatility drives
staleTime, not one global default. Document dual-cache story in an ADR.
Socket to pixels pipeline
Definition
WebSocket → parse frames → normalized map symbol → quote
→ store.applyTick → per-symbol selector → only visible rows re-render'use client';
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
type Quote = { symbol: string; price: number; ts: number };
const useMarket = create<{ quotes: Record<string, Quote>; applyTick: (tick: Quote) => void }>()(
subscribeWithSelector((set) => ({
quotes: {},
applyTick: (tick) =>
set((s) => ({ quotes: { ...s.quotes, [tick.symbol]: tick } })),
})),
);
function PriceCell({ symbol }: { symbol: string }) {
const price = useMarket((s) => s.quotes[symbol]?.price);
return <td>{price ?? '—'}</td>;
}WS message → O(1) patch one symbol
Parent setState → O(n) re-render all rows — forbidden
Row selector → O(1) re-render one cellNever setQuotes([...500 rows]) each tick. React Query is wrong for tick stream — use store + throttle for ticks, Query for REST entities.
Interview: Draw the pipeline and name fan-out cost explicitly.
Throttle display, not ingest
Definition
Ingest all ticks for audit/compliance. Throttle render flush — latest wins per symbol. Use startTransition for batched store updates. Config per asset class: equities 100ms, illiquid alts 500ms.
Throttle socket receive → wrong; lose audit data
Throttle render flush → correct; traders see latest price path500-symbol dashboard architecture
Real-world example
function Dashboard() {
useMarketSocket(process.env.NEXT_PUBLIC_WS_URL!);
const { data: positions } = useQuery({
queryKey: ['positions'],
queryFn: fetchPositions,
refetchInterval: 30_000,
});
const selected = useUiStore((s) => s.selectedSymbol);
return (
<div className="grid">
<VirtualTape symbols={WATCHLIST_500} />
<OrderTicket symbol={selected} />
<PositionsPanel rows={positions ?? []} />
</div>
);
}Reject → per-tick setState on parent; Query polling every 100ms for prices
Reject → one React state tree for tape + positions + news
Reject → Date.now() for compliance timestamps — use server ts
Workers → option greeks off main thread; postMessage into store
Chaos → kill WS; REST snapshot recovery + degraded mode bannerDisplay server ts from tick — never client-generated timestamps for compliance UI.
Senior Interview Summary
What to say
staleTime controls freshness — no auto-refetch while fresh. gcTime controls inactive memory retention after unmount. Market ticks flow through a normalized Zustand store with per-row selectors and a 100ms display throttle. Positions use React Query on REST with event-driven invalidation. SSR needs per-request QueryClient with staleTime matched to RSC revalidate.
Cache checklist
staleTime → freshness window; stale ≠ deleted
gcTime → inactive memory; was cacheTime in v4
Key factories → hierarchical prefix invalidation
SSR → per-request QueryClient; HydrationBoundary
Dual cache → revalidateTag + invalidateQueries on webhook
WS pipeline → normalize → per-symbol selector → throttle displayBest mental model
staleTime → "how long before I consider refetching"
gcTime → "how long inactive data stays in memory"
Ingest → "all ticks logged"
Display → "latest wins per symbol at 4–10/sec"Practical rule
Catalog → long staleTime, no focus refetch
Inventory → short staleTime or websocket
Cart → staleTime 0, optimistic + invalidate on every action
Never → parent setState per tick with 500 symbols
Chaos test → kill WS; verify REST fallback + degraded banner