Skip to content
academia.sh

Lesson 09 / 22

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.

Contents

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.

// 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close