---
title: 'Server State Management'
source: 'https://academia.sh/en/courses/frontend-architecture/server-state-management'
course: 'Application Architecture: Routing, State and Data'
language: en
updated: '2026-08-17T18:11:05+00:00'
license: 'CC BY-SA 4.0'
---

# Server State Management

The client-side copy of remote data — the cache key, the freshness window, in-flight deduplication, serving stale while revalidating in the background, invalidation after a write, the cache time, and refetch triggers.

The centralized store and the atom graph share the same assumption: the application
owns the state, the written value is correct. The North Slope Measurement Station
application's largest mass of state does not meet this assumption. The station list and
measurement records are a copy of a remote source; another user may have added a new
station, someone may have corrected a measurement.

The State Types lesson said this kind raises four separate questions: how long is the
copy considered current, when it goes stale when is it refreshed, if two components
request the same data how many requests are made, what appears on screen while a local
change is being written. The first three are the subject of this lesson, the fourth of
the next.

## The Key

The server state layer's first decision is what a copy is identified by. The **cache
key** is produced from the values that fully determine the request: the resource's path
and every parameter that differentiates the request.

A key that is missing something conflates two copies: a key that does not account for
the filter keeps temperature measurements and humidity measurements in the same entry.
A key that is broader than it needs to be prevents sharing; if a value that does not
affect the request enters the key, every view downloads its own copy.

The canonical form from the Route Parameters and Query lesson finds its counterpart
here. Because the same view is identified by a single address, the key derived from the
address is also singular; a change in parameter order does not create a second cache
entry.

## Freshness and Cache Time

The second decision is how long the copy is used without querying again. Two separate
durations are needed, and confusing them is a commonly seen defect.

The **freshness window** is the duration the copy can be used without being requested
again. A read inside the window never reaches the network. The **cache time** is the
duration the copy is kept in memory; once this duration elapses, the entry is dropped.

In the interval between the two, the copy is **stale**: it is shown to the user
immediately, and refreshed in the background at the same time. The screen is never left
empty, and the data never stays out of date.

```js
// server-state.mjs — freshness window, in-flight deduplication, invalidation, cache time
const FRESHNESS = 30;      // seconds: the copy is considered fresh within this duration
const CACHE_TIME = 120;    // seconds: the entry is dropped after this duration

const cache = new Map();      // key -> { data, receivedAt, invalid }
const inFlight = new Map();   // key -> request number
let requestNo = 0;

function request(key, clock) {                 // clock: virtual time supplied from outside
  const entry = cache.get(key);
  const age = entry ? clock - entry.receivedAt : null;
  if (entry && age > CACHE_TIME) { cache.delete(key); }
  const validEntry = cache.get(key);

  if (validEntry && !validEntry.invalid && clock - validEntry.receivedAt <= FRESHNESS)
    return { result: "cache hit", age: clock - validEntry.receivedAt, request: "-" };

  if (inFlight.has(key))
    return { result: "deduplicated in flight", age, request: `#${inFlight.get(key)}` };

  inFlight.set(key, ++requestNo);
  const result = validEntry
    ? "stale served + revalidating"
    : (entry ? "entry dropped, pending" : "no data, pending");
  return { result, age, request: `#${requestNo}` };
}

function complete(key, clock, data) {
  cache.set(key, { data, receivedAt: clock, invalid: false });
  const n = inFlight.get(key);
  inFlight.delete(key);
  return { result: "response written", age: 0, request: `#${n}` };
}

function invalidate(prefix) {                // called after a write completes
  let count = 0;
  for (const [key, entry] of cache)
    if (key.startsWith(prefix)) { entry.invalid = true; count += 1; }
  return { result: `${count} entries invalidated`, age: null, request: "-" };
}

const LIST = ["north-slope", "east-ridge"];
const STEPS = [
  [0, "List opened", "station/list", (t, k) => request(k, t)],
  [1, "SummaryCard requested same data", "station/list", (t, k) => request(k, t)],
  [3, "response arrived", "station/list", (t, k) => complete(k, t, LIST)],
  [5, "returned to tab", "station/list", (t, k) => request(k, t)],
  [40, "returned to tab", "station/list", (t, k) => request(k, t)],
  [42, "response arrived", "station/list", (t, k) => complete(k, t, LIST)],
  [45, "station detail opened", "station/north-slope", (t, k) => request(k, t)],
  [47, "response arrived", "station/north-slope", (t, k) => complete(k, t, { name: "North Slope" })],
  [50, "new station saved", "station/ (prefix)", (t) => invalidate("station/", t)],
  [51, "returned to list", "station/list", (t, k) => request(k, t)],
  [53, "response arrived", "station/list", (t, k) => complete(k, t, [...LIST, "west-valley"])],
  [200, "returned to detail", "station/north-slope", (t, k) => request(k, t)],
];

console.log("t".padStart(4), "event".padEnd(34), "key".padEnd(22), "result".padEnd(28), "age", "request");
for (const [t, event, key, run] of STEPS) {
  const r = run(t, key);
  console.log(String(t).padStart(4), event.padEnd(34), key.padEnd(22),
    r.result.padEnd(28), String(r.age ?? "-").padStart(3), r.request);
}

console.log("-- total network requests --", requestNo);
```

```
   t event                              key                    result                       age request
   0 List opened                        station/list           no data, pending               - #1
   1 SummaryCard requested same data    station/list           deduplicated in flight         - #1
   3 response arrived                   station/list           response written               0 #1
   5 returned to tab                    station/list           cache hit                      2 -
  40 returned to tab                    station/list           stale served + revalidating   37 #2
  42 response arrived                   station/list           response written               0 #2
  45 station detail opened              station/north-slope    no data, pending               - #3
  47 response arrived                   station/north-slope    response written               0 #3
  50 new station saved                  station/ (prefix)      2 entries invalidated          - -
  51 returned to list                   station/list           stale served + revalidating    9 #4
  53 response arrived                   station/list           response written               0 #4
 200 returned to detail                 station/north-slope    entry dropped, pending       153 #5
-- total network requests -- 5
```

Because time is supplied from outside, the output is deterministic; the same script
produces the same lines on every run. In a real application the source of this value is
the system clock, but the layer itself stays testable if it takes the clock as a
dependency.

## In-Flight Deduplication

The second row shows the layer's least visible gain. The list and the summary card
request the same data one second apart; only one request goes out to the network.

Without this behavior, every component on the same screen makes its own request. In the
waterfall view defined in the Network Panel Diagnostics lesson, this appears as
side-by-side requests going to the same address. The fix is not changing the components
— lifting the data up, distributing it from a single place — but merging requests that
go to the same key inside the layer. Components stay unaware of each other, and still
only one request is made.

For the merging to work, the key must be comparable by string equality. If key
generation is not canonical, two keys identifying the same request look different and
the merge is missed.

## Invalidation

The step at the fiftieth second shows the situation the freshness window cannot solve.
The user has saved a new station; the list's copy is now wrong, but its age is still
only nine seconds. Duration cannot supply this information.

What can supply it is the write operation itself. When a write completes successfully,
the keys it affects are **invalidated**: they are considered stale regardless of their
age. In the output, invalidation was done by prefix and affected two entries at once.

Determining the affected keys is a design decision. A narrow invalidation — only the
key of the changed record — is fast but leaves the list stale. A broad invalidation —
the entire prefix — is safe but produces unnecessary requests. The criterion is which
views the write actually affects; a new station changes both the list and the
summaries.

Invalidation has a sibling: writing the response directly. If the server returns the
current record in response to the write request, that record is placed in the cache
and no additional request is ever made. This also concerns the server side of the
contract and is covered in the Data Access topic.

## Cache Time

The last row occurs at the two-hundredth second. The station detail's copy has
exceeded the cache time and has been dropped; when the user returns, not even stale
data can be shown, and a wait state appears.

The reason cache time exists is memory. If every record the user opened once and never
returned to stays in memory, a tab left open for a long time keeps growing. This is one
of the causes producing the pattern seen in the memory leak diagnosis from the
Asynchronous JavaScript and the Runtime course.

The two durations are set together. The freshness window is chosen according to how
often the data changes: a station's rarely changing profile wants a long window, a
continuously streaming measurement list wants a short one. The cache time is chosen
according to the likelihood the user returns to that view.

## Refetch Triggers

When a stale entry is refreshed does not depend only on reading. The layer also
listens for a few external signals.

Returning to the tab is the most effective of these: when the user stays in another
application for minutes and comes back, everything on screen has gone stale. The
network connection re-establishing is the second signal. Refreshing at fixed intervals,
meanwhile, is used only for data that genuinely changes, and is stopped while the tab
is not visible.

Every trigger produces a cost. The criterion is comparing the cost of the user seeing
stale data against the cost of an extra request; staleness is expensive in the
measurement list, not in the station profile.

## Summary

- Server state is held in its own layer, and every copy is identified by a canonical
  key that fully determines the request.
- The freshness window is the duration the copy is used without querying again; the
  cache time is the duration it stays in memory. The copy in between is stale: it is
  shown and refreshed in the background.
- Concurrent requests to the same key are deduplicated in flight; only one request is
  made even while components stay unaware of each other.
- Duration-based freshness cannot know that a local write has invalidated the copy;
  invalidation after a write makes the affected keys stale regardless of their age.
- An entry whose cache time has elapsed is dropped, and not even stale data can be
  shown on return; this duration bounds memory.
- Refetching is triggered, besides reading, by returning to the tab, the connection
  re-establishing, and interval-based refresh; each trigger is chosen by weighing
  staleness cost against request cost.

## Next Step

This layer solved reading. On the write side, what appears on screen still depends on
the speed of the network: the user deletes a measurement, the button switches to
waiting, and the row disappears only once the server responds. Yet in most cases the
request will succeed and the result is already known in advance. The next lesson covers
reflecting a change onto the screen without waiting for the response, reverting to the
prior state on failure, and how that reversion can collide with other changes that
arrive in between.
