Skip to content

Revalidation & subscriptions

The client’s cache is not a passive store — it actively keeps GET data fresh. There are four mechanisms, all built on the same tag index and Observable subscription protocol.

Toapi servers attach cache tags to GET responses via the X-TAPI-Tags header (declared server-side with cache: { tags: [...] }). The client records which URLs carry which tags in a tag index.

Every mutation response (POST/PUT/PATCH/DELETE) may also carry an X-TAPI-Tags header listing the tags it invalidated. When such a response arrives, the client looks up every cached URL that shares any of those tags, drops it from the cache, and notifies subscribers so they can re-fetch. A tagged URL with no active subscriber is simply dropped and re-fetched lazily on its next .get() call.

// This POST responds with, say, tag "users".
// Any cached GET tagged "users" is revalidated automatically.
await client.users.post({ name: "Alice" });

The promise returned by a mutation is augmented with a .revalidated property:

type Revalidating = {
revalidated: Promise<void>;
};

.revalidated settles once the tag-based revalidation triggered by that mutation’s response has completed. Await it when you need dependent GETs to be up to date before proceeding:

const result = client.users.post({ name: "Alice" });
await result; // the created user
await result.revalidated; // all matching GET caches are now fresh

Call .revalidate(query?) on any route to drop its cached GET entry and notify subscribers, forcing a re-fetch. It resolves once that has happened:

await client.users.revalidate();
await client.users.get({ active: true }); // now served from fresh cache

When the server sends an X-TAPI-Expires-At header, the client schedules a background revalidation for that time (plus a small random jitter bounded by maxOverdueTTL, to avoid stampedes). This happens regardless of whether the entry currently has subscribers. maxOverdueTTL is configurable via createFetchClient options.

The client can receive invalidations pushed by the server, so open views stay current even when the change originates elsewhere (another user, a background job). This happens over a long-lived connection to the invalidations route:

  • INVALIDATIONS_ROUTE — re-exported from @toapi/client, equal to "/__tapi/invalidations". By default the client connects to apiUrl + INVALIDATIONS_ROUTE.
  • The stream is newline-delimited; each line is a space-separated list of tags to revalidate.
  • On (re)connect, the entire cache is invalidated and subscribers are notified, since it may have gone stale while disconnected.
  • The client reconnects automatically with exponential backoff on network errors.
  • In browsers with a controlling service worker, invalidations are instead delivered via postMessage (event type TAPI_INVALIDATE_TAGS, plus TAPI_CONNECT on (re)connect), and the direct stream is not opened.

Configure or disable this with the invalidationsUrl option:

// custom endpoint
createFetchClient<typeof api.routes>(apiUrl, {
invalidationsUrl: "https://example.com/api/__tapi/invalidations",
});
// disable server-push entirely
createFetchClient<typeof api.routes>(apiUrl, { invalidationsUrl: false });

A GET that rejects (a non-2xx response, or a network error) is removed from the cache as soon as it fails, and the error is reported via options.logger.error. The next .get() call for that URL issues a fresh request rather than replaying the same rejection.

All revalidation ultimately surfaces through subscribers. Awaiting a .get() gives you a one-shot value; subscribing registers a callback for every subsequent value:

const result = client.todos.get();
const unsubscribe = result.subscribe((next) => {
next.then((todos) => render(todos));
});
// A mutation elsewhere, a server push, or a timed revalidation
// will now re-run this callback with fresh data.
unsubscribe(); // stop listening

See Observable for the subscription API in detail.