Skip to content

Caching remote screens: fresh, stale or gone?

Server-driven UIDeep dive8 min readMiniwall team

Part of the series: Server-driven UI: a field guide for app teams

On this page

Once a screen lives on a server, every copy of it on a device is in one of three states: fresh, stale but still usable, or gone. Show the first two straight away and refresh behind them. When it's gone, a screen that exists to sell something should usually not appear at all, rather than appear wrong.

That's the opinion. The rest of this post is the reasoning, plus enough code to try it.

Steps in order, with who does each: App or Server.

  1. App: Open: read the saved copy
  2. App: Fresh: show it, no request
  3. App: Gone: fetch it, or show nothing
  4. App: Stale: show it now
  5. Server: Ask if it changed (ETag)
Open the screen from the saved copy first. Only a missing copy makes the person wait. Shape only, not data.

Three states, and who decides each

HTTP caching already has words for the first two. RFC 9111 says "A fresh response is one whose age has not yet exceeded its freshness lifetime."[1] Past that point the response is stale, and by default a cache may not hand out a stale response without checking. The RFC's wording is that a cache "MUST NOT generate a stale response unless it is disconnected or doing so is explicitly permitted."[1] The word to notice is "explicitly". Stale isn't forbidden. You have to say it's allowed.

The third state, gone, is ours to define. Maybe the copy was never saved. Maybe it's older than you're willing to show. Maybe the server told you the screen no longer exists.

StateWhat you knowWhat to do
FreshYounger than your freshness windowShow it. Don't ask the server
Stale but usablePast the window, younger than your stale limitShow it now, check with the server behind it
GoneNo copy, too old, or the server said it's removedFetch and wait briefly, or show nothing

Swipe sideways to see every column.

Two numbers decide everything: how long a copy counts as fresh, and how long past that it may still be shown. Pick them per screen type, not once for the app. A "what's new" card can be stale for a week. A screen with a sale on it can't.

Cache-first, then refresh behind it

The pattern is old and plain: answer from the saved copy, and update the saved copy for next time. The HTTP version has a name. MDN describes stale-while-revalidate as indicating "that the cache could reuse a stale response while it revalidates it to a cache", and gives the example Cache-Control: max-age=604800, stale-while-revalidate=86400[2]. Fresh for seven days, then usable for one more day while the cache checks in the background. Its sibling stale-if-error allows a stale response when the upstream server errors, and MDN counts 500, 502, 503 and 504 as errors for that purpose[2]. RFC 9111 itself points to these as extension directives, such as those defined in RFC 5861[1].

Before

Network first

  1. Screen requested
  2. Wait for the network
  3. Show it

After

Cache first, refresh behind

  1. Screen requested
  2. Show the saved copy
  3. Fetch in the background
  4. Next open uses the new copy
Same screen, different order of operations. Shape only, not data.

There's a cost, and it's worth saying plainly: the person who opens the screen right after you publish sees the old one. With cache-first, a change reaches someone on their next open, not their current one. If that's unacceptable for a particular screen (a legal notice, say), it needs a short stale limit or a network-first path.

Here's a server that sends the header and answers conditional requests. It's an illustration for this post, not any product's API. It uses only Node's standard modules (Node 18 or later; we haven't tested older versions).

server.mjs
// Illustration only, not any product's API. Run: node server.mjs
import { createServer } from "node:http";
import { createHash } from "node:crypto";

const screen = JSON.stringify({
  schema: 1,
  children: [
    { type: "title", text: "Export without limits" },
    { type: "price", planId: "pro_monthly" }, // an ID, not a price
    { type: "button", text: "Upgrade", action: "purchase", planId: "pro_monthly" },
  ],
});
const etag = `"${createHash("sha256").update(screen).digest("base64url").slice(0, 16)}"`;

createServer((req, res) => {
  if (req.url !== "/screens/upgrade.json") return res.writeHead(404).end();

  const headers = {
    "Content-Type": "application/json",
    ETag: etag,
    // fresh 60 s, usable while revalidating for 10 min, usable on server errors for a day
    "Cache-Control": "max-age=60, stale-while-revalidate=600, stale-if-error=86400",
  };
  // Simplified: compares one validator. A real server should also handle a list of them.
  if (req.headers["if-none-match"] === etag) return res.writeHead(304, headers).end();
  res.writeHead(200, headers).end(screen);
}).listen(3000);

Those headers are for the HTTP caches between you and the app: the browser, a CDN. Whether each one honors stale-while-revalidate is something we haven't verified for any particular cache, so check the ones you depend on. That uncertainty is one reason we like keeping the policy in the client too, where you can see it:

get-screen.js
// Illustration only, not any product's API. Browser module, or any runtime with fetch + localStorage.
const KEY = "screen:upgrade";
const FRESH_MS = 60_000;             // show without asking
const USABLE_MS = 24 * 3600_000;     // show stale for up to a day; after that it's gone

const read = () => { try { return JSON.parse(localStorage.getItem(KEY)); } catch { return null; } };
const write = (entry) => { try { localStorage.setItem(KEY, JSON.stringify(entry)); } catch {} };
const isScreen = (s) => s && s.schema === 1 && Array.isArray(s.children); // refuse what we can't draw

async function revalidate(url, saved) {
  const headers = saved?.etag ? { "If-None-Match": saved.etag } : {};
  const res = await fetch(url, { headers, cache: "no-store", signal: AbortSignal.timeout(8000) });

  if (res.status === 304 && saved) {            // unchanged: keep the body, reset the clock
    const entry = { ...saved, savedAt: Date.now() };
    write(entry);
    return entry;
  }
  if (res.status >= 400 && res.status < 500) {  // the screen is gone: drop the copy
    try { localStorage.removeItem(KEY); } catch {}
    throw new Error(`screen ${res.status}`);
  }
  if (!res.ok) throw new Error(`screen ${res.status}`); // 5xx: keep the copy
  const screen = await res.json();
  if (!isScreen(screen)) throw new Error("unusable screen");
  const entry = { screen, etag: res.headers.get("ETag"), savedAt: Date.now() };
  write(entry);
  return entry;
}

export async function getScreen(url) {
  const saved = read();
  const age = saved ? Date.now() - saved.savedAt : Infinity;

  if (saved && age < FRESH_MS) return saved.screen;          // fresh
  if (saved && age < USABLE_MS) {                            // stale but usable
    revalidate(url, saved).catch(() => {});                  // refresh for next time
    return saved.screen;
  }
  try { return (await revalidate(url, saved)).screen; }      // gone: wait for the network
  catch { return null; }                                     // still gone: show nothing
}

Three details are deliberate. cache: "no-store" stops the browser's own HTTP cache from answering, so our validator and our clock are the only ones in play. A 304 resets the clock but keeps the body, which is the whole point of the ETag. And a 4xx deletes the copy, because "the server says this screen doesn't exist" is a different message from "the server is down". We haven't covered concurrent calls, which would each start their own refresh. Share one in-flight promise if that matters to you.

The price is not in the screen

Here's the question that actually worries people. If the screen is cached, doesn't it show an old price?

Only if you put the price in it. Our rule, and it's a design opinion rather than a standard: the screen says which plan, and the app says how much. Look at the price component in the server example. It carries planId: "pro_monthly", nothing more. When the screen is drawn, your code looks up the current price, currency and trial for that ID and passes them in. We wrote about the same split in what server-driven UI is: the description names things, and your code supplies what they mean.

That puts the two kinds of change on different clocks. Layout, wording and which plan is highlighted can be minutes or days old without harm. Money can't. Price, currency, tax and trial length are data your billing system owns, read at render time, so no cache of the screen can make them wrong.

It's not a complete fix. Words inside the screen can still go out of date. "Sale ends Friday" doesn't update itself. So treat any screen with a deadline in its copy as short-lived: a small stale limit, or the end date held in your code and checked before showing. Be suspicious of any copy that contains a price typed as text.

Offline

A saved copy makes the screen available offline, and that's mostly good. An onboarding step, a feature tour or an "about this release" card should just work without a connection.

A screen whose button needs the network is a different case. If the only action is "Buy", a paywall that appears on a plane and fails at the tap is worse than no paywall. So we'd decide per screen. Informational screens can use a long stale limit. Screens that lead to checkout can be shown from the saved copy only when your app also knows the purchase can go through, or when the tap handler says what's wrong in plain words. "You're offline. Connect to subscribe" beats a spinner.

The first launch with no connection has no saved copy at all. Either ship a default in the build for the screens that matter, or accept that the screen won't appear that time.

When it's gone: old copy, nothing, or a default?

You have three choices when there's no good copy and the network isn't helping.

Show the old one anyway. Reasonable for content that stays true: a changelog, a tour. Risky for anything with a price, a deadline or a claim.

Show nothing. The app carries on without the screen. For a screen whose job is to promote something, our view is that this usually wins. A missed upsell costs one impression. A wrong offer costs a support ticket and trust, and the person who saw it may not come back to it. Not showing is also the choice that's easiest to undo.

Show a default from the build. Good when the moment matters more than the message: a "continue" button after onboarding, a legal notice that must appear. It costs you a second copy of that screen to keep in step with the first, so spend it only on screens that earn it.

Our default, then, is nothing for promotional screens, a bundled default for required ones, and the stale copy for informational ones. It's a default, and the right answer depends on what a wrong screen costs you.

Old copies and new schemas

A saved copy can outlive the code that draws it, and it can outlive the schema it was written in. The example getScreen checks schema === 1 and returns nothing for a screen it can't draw. That's a blunt rule, and a refusal is a safer failure than a half-drawn screen. A gentler approach is to skip a component you don't know and keep the rest, as in the renderer sketch in that same post. Which one you pick matters less than choosing before the first app version ships, because every version in the wild is a promise you've already made.

Also keep a version or a hash with each saved copy, so you can tell which published version someone saw when they report a bug.

When not to bother

If a screen changes twice a year, put it in the build. If every screen is tiny and the endpoint is fast and always reachable, ordinary HTTP caching with a short max-age may be all you need and you can skip the client logic above. And if your screens carry anything you wouldn't want shown stale (a balance, a stock level), they're not candidates for this pattern. They're data, and they should be read fresh.

Sources

Go further