Documentation/frontend/Caching
In this section

Caching

netron-browser caches HTTP responses through its fluent HTTP interface (HttpCacheManager); netron-react layers a query cache on top with React-aware subscriptions.

Two cache layers

LayerWhen to use
netron-browser fluent cache (HttpCacheManager)Vanilla JS / web workers / non-React HTTP clients
netron-react QueryCacheReact apps — used by useQuery

Both share the same TTL / stale-while-revalidate / tag invalidation semantics. Pick one — don't double-cache.

The fluent cache is opt-in per call (.cache(...)) on a service obtained via peer.queryFluentInterface(...). The basic createClient() / HttpClient path does not cache — its http.caching / http.cacheTTL options are currently inert.

netron-react QueryCache

Used automatically by every useQuery / useService(...).x.useQuery:

const { data, isLoading, isStale } = users.getUser.useQuery([userId], {
  staleTime:    30_000,
  cacheTime:    5 * 60_000,
  refetchOnWindowFocus:  true,
  refetchOnReconnect:    true,
  refetchInterval:       60_000,
});
OptionDefaultMeaning
staleTime0Data is fresh for N ms; while fresh, no refetch on subscribe
cacheTime5 * 60_000Drop from cache N ms after last subscriber unmounts
refetchOnMounttrueRefetch when a component mounts subscribing to this key
refetchOnWindowFocusfalseRefetch when tab regains focus
refetchOnReconnecttrueRefetch when WS / network reconnects
refetchIntervalfalsePeriodic refetch in ms
enabledtrueSkip the query when false
placeholderData—Initial value before first fetch
select—Transform data per subscription

Cache key

Generated from [service, method, args]:

// All these are the same cache key:
users.getUser.useQuery(['u_42']);
users.getUser.useQuery(['u_42']);

// These are different keys:
users.getUser.useQuery(['u_42']);
users.getUser.useQuery(['u_43']);

Args are deep-equal-compared — {a:1, b:2} and {b:2, a:1} match.

Programmatic cache access

import { useNetronClient } from '@omnitron-dev/netron-react';

function CacheManager() {
  const client = useNetronClient();
  const cache  = client.getQueryCache();

  return (
    <>
      <Button onClick={() => client.invalidateQueries({ queryKey: ['users'] })}>
        Refresh all users queries
      </Button>
      <Button onClick={() => client.removeQueries({ queryKey: ['users', 'getUser', 'u_42'] })}>
        Drop one specific
      </Button>
      <Button onClick={() => cache.set(['users', 'getUser', 'u_42'], updatedUser)}>
        Patch cached value
      </Button>
      <Button onClick={() => client.clear()}>
        Nuke everything
      </Button>
    </>
  );
}

The TanStack-style filter methods live on the client; the low-level read/write methods live on the QueryCache (client.getQueryCache()):

MethodLives onEffect
getQueryCache().get(key)cacheRead cached data without subscribing
getQueryCache().set(key, data)cacheWrite to cache directly
invalidateQueries(filters)clientMark stale → subscribers refetch
removeQueries(filters)clientDrop from cache
cancelQueries(filters)clientAbort in-flight queries matching filter
prefetchQuery(key, fetcher)clientWarm cache without rendering
clear()clientDrop everything

To patch a cached value functionally, read-then-set:

const prev = cache.get(['users', 'getUser', 'u_42']);
cache.set(['users', 'getUser', 'u_42'], { ...prev, ...patch });

Filter patterns

The client's filter methods take a QueryFilters object — { queryKey?, exact?, status?, stale?, fetching?, predicate? }. queryKey is a prefix match by default; add exact: true for an exact match.

client.invalidateQueries({ queryKey: ['users'] });                  // every users.* query
client.invalidateQueries({ queryKey: ['users', 'getUser'] });       // every getUser
client.invalidateQueries({ queryKey: ['users', 'getUser', 'u_42'], exact: true }); // one specific
client.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'users' && q.state.dataUpdatedAt < Date.now() - 60_000 });

The functional predicate form lets you invalidate by age, state, or any custom condition.

Stale-while-revalidate

users.getUser.useQuery([userId], {
  staleTime: 30_000,
  cacheTime: 5 * 60_000,
});

Flow when component mounts:

flowchart LR
  Mount[Component mounts]
  Mount --> Check{In cache?}
  Check -- no --> Fetch[fetch + return]
  Check -- fresh --> Return[return cached]
  Check -- stale --> Revalidate[return stale + fetch in background]
  Revalidate --> UpdateCache[on success: cache update + re-render]

isStale is exposed in the result — components can show a subtle "refreshing" indicator while stale-while-revalidate runs.

Optimistic updates

const updateProfile = users.updateProfile.useMutation({
  onMutate: async (newProfile) => {
    // 1. Cancel any in-flight queries that would clobber the optimistic update
    client.cancelQueries({ queryKey: ['users', 'getUser', userId] });

    // 2. Snapshot the previous value
    const previous = cache.get(['users', 'getUser', userId]);

    // 3. Optimistically update
    cache.set(['users', 'getUser', userId], {
      ...previous,
      ...newProfile,
    });

    // 4. Return context for rollback
    return { previous };
  },
  onError: (err, _newProfile, context) => {
    // Rollback to snapshot
    if (context?.previous) {
      cache.set(['users', 'getUser', userId], context.previous);
    }
  },
  onSettled: () => {
    // Always re-fetch authoritative state
    client.invalidateQueries({ queryKey: ['users', 'getUser', userId] });
  },
});

Pattern:

  1. Cancel to avoid race with in-flight fetches.

  2. Snapshot for rollback.

  3. Optimistic write for instant UI feedback.

  4. Rollback on error — restore snapshot.

  5. Invalidate on settled — re-sync with server truth.

Tag-based invalidation

For non-React HTTP clients, attach an HttpCacheManager to the peer and tag calls through the fluent .cache({ tags }):

import { HttpCacheManager } from '@omnitron-dev/netron-browser';

const cache = new HttpCacheManager({ maxEntries: 1_000 });
peer.setCacheManager(cache);

const users = await peer.queryFluentInterface<UserService>('users@1.0.0');

// Tag a cached call with arbitrary labels:
await users
  .cache({
    maxAge:               60_000,
    staleWhileRevalidate: 10_000,
    tags:                 ['user:u_42', 'tier:pro'],
  })
  .api.getUser('u_42');

// Later, when user u_42 changes — invalidate by tag.
// (The array form of invalidate() matches tags; a bare string
// matches cache keys, so pass tags as an array.)
cache.invalidate(['user:u_42']);

// Or all pro-tier cached data:
cache.invalidate(['tier:pro']);

Tags are arbitrary strings — typical patterns:

  • user:<id> — invalidate one user's queries

  • tenant:<id> — invalidate one tenant's queries

  • entity:<type> — invalidate one collection

  • feature:<flag> — invalidate when a flag flips

A single cache entry can carry multiple tags; invalidating any one drops the entry. cache.invalidate() also accepts a key RegExp or a 'prefix*' string for pattern-based invalidation.

Prefetching

import { useNetronClient, useService } from '@omnitron-dev/netron-react';

function ProjectLink({ id }: { id: string }) {
  const client   = useNetronClient();
  const projects = useService<ProjectService>('projects');
  return (
    <Link
      to={`/projects/${id}`}
      onMouseEnter={() => {
        client.prefetchQuery(
          ['projects', 'getProject', id],
          () => projects.getProject.call(id),
        );
      }}
    >
      {name}
    </Link>
  );
}

When the user hovers a link, prefetch the target's data so the destination renders instantly.

Cache stats

The fluent HttpCacheManager exposes hit/miss statistics:

const stats = cache.getStats();
// CacheStats:
//   { entries, hits, misses, hitRate, sizeBytes, activeRevalidations }

Surface in devtools or a metrics dashboard.

React Query parity

netron-react's QueryCache API is intentionally TanStack Query–compatible at the high level — if you've used React Query, the mental model carries over. The differences:

  • Cache key is [service, method, args] not free-form.

  • Backed by netron-browser, not raw fetch.

  • Subscribes via useService / useQuery hooks; same shape.

Best practices

  • staleTime ≥ 30 s for most read queries — saves needless refetches.

  • refetchOnWindowFocus: true for dashboards and admin surfaces — keeps data fresh after returning to a tab.

  • Optimistic updates for fast-feedback mutations — but always invalidate on settled to converge with server truth.

  • Invalidate by widest sensible key after mutations — invalidating ['users'] after creating a user catches list

    • count + getUser callers.

  • Use tags for non-React clients — easier to reason about than key patterns.

  • Don't cache mutating calls. delete / update should never read from cache.

Anti-patterns

  • staleTime: 0 everywhere. Defeats caching; every mount refetches.

  • cacheTime: Infinity. Cache grows unboundedly; first call after page reload is cold anyway.

  • Manual cache writes from multiple components. Use invalidation; manual writes drift.

  • Caching paginated lists without page-aware keys. [users, list, {page: 1}] and [users, list, {page: 2}] must be separate keys.

  • Invalidating after every keystroke. Debounce or move to onBlur.

See also