Frontend System Design/Frontend Data Layer
Lesson 20 of 20 · Episode 20

Frontend Data Layer — Reading Server State

Design a trustworthy read path with shared query identity, freshness budgets, race-safe caching, and precise invalidation.

Server stateQuery identityCachingInvalidation
Watch on YouTube ↗

Fetching is not merely “call an endpoint and put the response in state.” The moment two views read the same server entity, the frontend needs adata layer: a shared answer to what the data is, who owns it, how long it can be trusted, and what happens when the server changes.

The missing link

The read path needs four separate decisions

Earlier lessons covered the amount of data to request through pagination, its client-side shape through normalization, and the transport of changes through polling, SSE, and WebSockets. One decision remained: whenshould a view read, reuse, or refresh server data?

InteractiveTrace one read from observer to server
1
Views
who needs data
2
Identity + cache
what data is this
3
Read policy
when should it refresh
4
Transport
how it reaches the server
The board, detail panel, and counters observe data. They describe what they need, not how to fetch it.
Click a layer. The read path is a pipeline; no component should own all four decisions.

A production read path separates observers from query identity, read policy, and transport. That separation lets a board, a detail panel, and a counter ask for related data without inventing three unrelated caching systems inside three components.

The cache is the middle, not the edge
Components observe a query. Transports feed the query cache. Neither the HTTP client nor a WebSocket listener should secretly update one component while every other view remains unaware.
First classify the state

Not everything in the browser belongs in the data layer

A drawer being open and issue #42 being closed are both “state,” but they have different owners and lifecycles. Treating them alike creates bloated global stores or local copies of server truth.

InteractiveWho owns this state?
Is the compose modal open?
Caching rules belong to server-originated state, not every value in the browser.
OwnerTypical examples
Local UIComponentModal open, active tab, draft input
LocationURLPage, sort order, shareable filters
Server stateServer + query cacheIssues, users, counts, permissions
Persisted localBrowser storageUnsaved draft, preference, offline hint
This lesson targets server-originated state
The server is authoritative, but the browser keeps a time-bounded local representation so reads are fast, shareable, and resilient.
One entity, two stories

Independent fetches create split-brain screens

Imagine a project board fetched issue #42 while it was open. A detail panel later fetches the same issue after it closed. Both responses were correct when they arrived, yet the page now contradicts itself. The bug is not the endpoint; it is the absence of a shared identity and cache.

InteractiveIssue #42: which view is telling the truth?
Board viewOPEN
issue:42 · board fetch
Detail viewOPEN
issue:42 · detail fetch
Both views currently agree. Fetch the newer server value.
Fetch the detail, then introduce a shared cache and repeat the update.

A shared cache gives both observers one place to read the current server representation. It can also deduplicate simultaneous requests and notify every observer when that representation changes.

Concern 1 · Identity

Equivalent reads need the same stable key

“Issues” is not a complete identity. Project, status, pagination cursor, sort order, and every input that can change the response belong in the key. If equivalent reads produce equivalent keys, the data layer can safely share work.

InteractiveBuild a stable query identity
FILTERS
CANONICAL QUERY KEY
["issues", { "project": "alpha", "status": "open" }]
1 network request · 2 observers · 1 cached result
Same canonical key means one logical query, one in-flight request, and one shared result.
A canonical query key
const queryKey = [
  "issues",
  { project: "alpha", status: "open" },
] as const;

useQuery({
  queryKey,
  queryFn: () => getIssues({ project: "alpha", status: "open" }),
});
A key is part of the correctness contract
Omit a response-shaping parameter and unrelated reads collide. Include an unstable object or timestamp and equivalent reads never deduplicate. Build keys from stable, serializable inputs.
Concern 2 · Lifecycle

Stale is a policy state, not a synonym for incorrect

Cached data moves through a lifecycle. Before the first read it is missing. After a successful fetch it is fresh. Once its freshness budget expires it becomes stale: still renderable, but now eligible for a background check. With no observers it becomes inactive, then is removed after its retention window.

InteractiveMove a query through its cache lifecycle
No cache entry — a read must fetch.
Stale does not mean wrong. It means the freshness promise has expired.

Choose a freshness budget from product risk

A country list may remain fresh for a day; a trading price may expire in seconds. The right number comes from how quickly the server changes and how costly stale UI would be—not from a library default.

Freshness is an application policy
useQuery({
  queryKey: ["issue", 42],
  queryFn: () => getIssue(42),
  staleTime: _000,       // trust for 30 seconds
  gcTime: 5 * _000,     // retain after the last observer leaves
  refetchOnWindowFocus: true,
});
Concern 3 · Request ordering

The latest response is not always the latest intent

A user searches for “re,” then quickly types “react.” The newer request can finish first while the older, broader search arrives last. Writing every response into one results variable lets network timing overwrite user intent.

InteractiveTwo searches, responses in the wrong order
REQUESTS
“re” · slow“react” · fast
Type to search…
The request for “re” returns last. Arrival order must not decide what the current view renders.

Keep each result under its own query key and render the active key. The slow “re” response can still be cached—its network cost was already paid—but it must not replace the active “react” view.

Identity makes races harmless
const activeKey = ["search", debouncedTerm];
const result = useQuery({
  queryKey: activeKey,
  queryFn: ({ signal }) => search(debouncedTerm, { signal }),
});

// "re" and "react" populate separate cache entries.
// The view observes only activeKey.
Concern 4 · Recovery

A single write can invalidate several reads

Closing issue #42 changes its detail, removes it from the open list, and changes the project count. It may not change the assigned user record. The data layer must model that blast radius.

InteractiveChoose the blast radius for issue.changed { id: 42 }
issue.changed · id 42 · version 8
3 stale left behind0 unnecessary refetch
Select every cached query that could be affected—without refetching unrelated data.
FailureConsequence
Under-invalidationToo few related queries refreshedContradictory or stale UI survives
Over-invalidationBroad prefixes refreshed every timeExtra traffic, loading, and server work
Precise invalidationKnown affected identities marked staleCorrect UI with bounded work
Invalidation is often safer than manual surgery
If a mutation changes relationships you cannot confidently patch, mark the affected queries stale and reuse the normal read path. Direct cache updates are best when the server returns a canonical object and the merge rules are explicit.
Realtime meets the read path

Signals should converge through the shared cache

A polling response, SSE event, or WebSocket message does not replace the data layer. It supplies evidence that data changed. The cache maps that signal to query identities, checks policy, and updates every observer.

InteractiveRoute real-time updates through the cache
Realtime signal
→
Mark issue:42 stale
→
HTTP refetch
→
Shared cache
→
All views
Tiny payload and one familiar read path; the trade-off is another request.
The transport reports change. The shared cache decides how every observer learns about it.
Notify-and-refetch
socket.on("issue.changed", ({ id }) => {
  queryClient.invalidateQueries({ queryKey: ["issue", id] });
  queryClient.invalidateQueries({ queryKey: ["issues"] });
  queryClient.invalidateQueries({ queryKey: ["project", "issue-count"] });
});

A small notification keeps the event schema light and reuses proven HTTP reads, at the cost of another round trip. A canonical snapshot avoids that read, but needs validation and merge handlers that update every affected cache entry consistently.

Framework agnostic by design
TanStack Query, RTK Query, Apollo, Vue Query, and other tools expose different APIs, but the architecture is the same: stable identity, shared observations, freshness policy, invalidation, and a transport.
Practice

Check your understanding

Q1Multiple choice
A cached query becomes stale. What does that state mean?
Q2Multiple choice
The “re” search response arrives after the newer “react” response. What should the data layer do?
Q3Multiple choice
Where should an issue.changed WebSocket event enter the UI architecture?
Q4Sort each scenario
Classify each value by its natural owner.
Whether a confirmation modal is open
A shareable status=open filter
The current status of issue #42
Try it yourself

Design challenge

Design the read path for an issue tracker with a board, detail drawer, project counts, search, offline reconnection, and WebSocket change notifications. Write down:

  1. The canonical query key for every read, including filters and pagination inputs.
  2. The freshness budget and retention time for detail, lists, counts, and search.
  3. Which views observe the same cache entry and which need distinct identities.
  4. How search prevents older responses from overwriting the active term.
  5. The exact invalidation blast radius of create, close, assign, and delete mutations.
  6. Whether each real-time event carries a small notification or canonical snapshot.
Key takeaways
  • →Separate views, query identity, read policy, and transport into four explicit layers.
  • →Classify state before storing it: local UI, URL, server-originated, and persisted local state have different owners.
  • →A stable query key gives equivalent observers one request, one cache entry, and one lifecycle.
  • →Freshness is a product promise; stale data may still render while the client revalidates it.
  • →Key responses by request identity so network arrival order cannot overwrite the user's latest intent.
  • →Balance under- and over-invalidation by mapping writes and real-time signals to the precise reads they affect.
  • →Keep the shared cache between transports and components so every view converges on the same server state.
← Previous
19. Real-time Updates — WebSockets