All articles & videos

Networking and caching in enterprise apps: own the stale data

Networking and caching for enterprise frontends: one data-access boundary, typed API contracts, cancel and , Cache-Control, safe retries.

Adil Ben Moussa
08 November 2026· 6 min
Networking and caching in enterprise apps: own the stale data

A customer makes a transfer, goes back to the dashboard, and sees the old balance. The transfer worked. The dashboard read a cache that nobody told about the write. In a bank, that is a support call, and sometimes a second transfer.

Caching sits in this part, next to networking, because in an enterprise app they are one question: where does this data live, and when is it wrong? A cache without an owner is a bug with a delay.

One boundary to the backend

Components never fetch. One data-access layer per domain talks to the backend, and everything else reads from it. That gives you one place to add auth, one place to cancel, one place to .

When the screens need a shape the domain APIs do not have, put a (BFF) between them, owned by the frontend team. Do it for shape and for secrets, not to avoid talking to the API team.

The contract is generated, and required is the truth

Generate the types from the document instead of writing them by hand. OpenAPI 3.1 schemas are "a superset of the JSON Schema Specification Draft 2020-12", and in "the properties defined by the properties keyword are not required" unless a required list names them. So read the required array, not the field list, to know what will be there.

Cancel, dedupe, time out

Three things every request needs. Cancel what the user left: 's httpResource "cancels the outstanding request before issuing a new one" when its inputs change. Without that, React's docs describe the result, a where an older response arrives last and the screen shows the wrong results. In plain fetch, AbortController lets you "abort one or more Web requests as and when desired".

Dedupe what is already in flight, so five widgets asking for accounts cause one request. And give every call a timeout, so a slow backend becomes an error message, not a spinner.

After a write, who refreshes the cache?

To invalidate a cache entry is to mark it as out of date, so the next read goes to the server again. Every cache needs an owner, and the owner is the code that performs the write. After a transfer, the accounts store either refetches or applies the server's answer. A widget that keeps its own copy will never hear about it.

In React without a framework, the data layer is yours to choose or build. Its docs list caching, server fetching and as the hard parts, and point to frameworks' built-in data fetching over effects. Whatever library you pick, the decision stays the same: which keys does this write make wrong?

The code that writes the data owns the cache of it.

On mobile in Morocco, cache first

On mobile in Morocco, caching is not an optimisation. Outside the big cities the connection is often slow or gone, and a screen that waits for the network is a screen people close. In Qismi, our school app, we solved that with heavy caching:

  • Two levels. Every list sits in memory and in the phone's storage, each entry with a (TTL): five minutes for message threads, one hour for announcements.
  • Cache first, refresh behind. The screen shows the cached copy at once and fetches a fresh one in the background. This pattern is called .
  • The network decides when. On Wi-Fi a cached list counts as stale after 20% of its TTL, on 3G after 80%, on 2G after 95%, and offline it is never refreshed. Slow connections get speed, fast ones get fresh data.
  • The write still owns the cache. After a message is sent, the code that sent it clears four entries: both people's thread lists, the conversation, and the receiver's unread count.

Image: tt-tech.io, adapted from web.dev, Keeping things fresh with stale-while-revalidate, CC BY 4.0.

The picture shows the same pattern for an HTTP response with a one-second max-age and a 59-second stale-while-revalidate window.

The same rule as above, just with more caches to own.

HTTP caching headers

The is the second cache, and in banking the one that leaks. no-store means "any caches of any kind (private or shared) should not store this response"; private is for "user-personalized content". Personalized content in a shared cache "may cause unintentional information leakage". Hashed build assets are the opposite case: long max-age plus immutable.

Image: tt-tech.io, adapted from web.dev, Prevent unnecessary network requests with the HTTP Cache, CC BY 4.0.

Retries and idempotency

Retrying a GET is safe. Retrying a transfer is not. POST and PATCH "are not guaranteed to be idempotent", and a client can safely retry a request "that uses an method". So a write either carries a key the server deduplicates, or it is never retried automatically. When the server answers 409 Conflict, "a request conflict with the current state of the target resource", show it to the user and reload; do not retry.

are where the cross-cutting parts live: auth headers, retries, timeouts, logging. Keep the retries to reads. I would add a there too, so an audit log on the server can be matched to what the user clicked.

On Atlas Bank

Atlas has no real server. FakeBackend in libs/data-access/banking is the boundary (its comment says "nothing else in the app talks to 'the server'"), with 250 ms of simulated latency so loading states are real. AccountsStore.load() dedupes:

  /** Idempotent: concurrent callers share one in-flight request; later callers resolve immediately. */
  load(): Promise<void> {
    if (this.loadedState()) return Promise.resolve();
    this.inFlight ??= this.fetchAll();
    return this.inFlight;
  }

After a transfer, the same store adds the transaction the backend returns and subtracts the amount from the balance, so every screen that reads it is right. Lint fails on a fetch to an absolute URL, because "the browser never calls a third party directly". What Atlas does not have yet: cancellation, timeouts, and an on transfer(). Those come with a real HTTP layer.

What I left out, and why

  • versus . In enterprise, the backend usually decides.
  • , , as a tour. When you need real-time, it is one decision about one feed.
  • and on the web. Rare in regulated web apps. On mobile it is a different story, as Qismi shows.
  • edge caching. Operations, not frontend design.

On Monday

  • Search your app for fetch( and HttpClient outside the data-access layer. Each hit is a missing boundary.
  • Pick your most important write. Write down which cached data it makes wrong, and who refreshes it.
  • Check the Cache-Control header on your most sensitive response.

Next: Part 4, on rendering.

For your coding agent

The rules of this article, written for an agent. Paste them into your AGENTS.md, save them as a skill, or point your agent at the file.

For your coding agent Download .md
# Networking and caching rules for enterprise apps

Source: https://tt-tech.io/blog/enterprise-frontend-networking-caching
For Angular and React frontends. Add to AGENTS.md or save as a skill. Your project's own rules win where they differ.

## Rules

1. Components never fetch. All backend calls go through one data-access layer per domain. No `fetch(` or `HttpClient` outside it.
2. Add a backend for frontend (BFF), owned by the frontend team, only when the screens need a shape the domain APIs do not have, or to keep secrets out of the browser.
3. Generate API types from the OpenAPI document. Never write them by hand. Treat a field as always present only when the schema's `required` array names it.
4. Cancel requests the user left: `httpResource` in Angular, `AbortController` with `fetch`. An older response must never overwrite a newer one.
5. Dedupe requests already in flight, so several widgets asking for the same data cause one request.
6. Give every call a timeout, so a slow backend becomes an error message, not an endless spinner.
7. The code that performs a write owns the cache of that data. After the write, refetch or apply the server's answer in the owning store. Never keep a private copy in a widget.
8. Set `Cache-Control` on purpose: `no-store` for responses that must not be reused (an account balance), `private` for user-personalised content, never personalised content in a shared cache, and a long `max-age` plus `immutable` for hashed build assets.
9. Retry reads only. Never retry a `POST` or `PATCH` automatically unless it carries an idempotency key that the server deduplicates.
10. On `409 Conflict`, show the conflict to the user and reload. Do not retry.
11. Put cross-cutting concerns in interceptors: auth headers, timeouts, read retries, logging, and a correlation id per request so server logs can be matched to what the user clicked.
12. On mobile, where the connection is often slow or gone, cache first: show the cached copy at once and refresh it in the background (stale-while-revalidate), with a time to live per entry. The write still clears what it makes wrong.

## Before you say done

- Search the diff for `fetch(` and `HttpClient` outside the data-access layer. Move each hit behind the boundary.
- For every write in the diff, list in the pull request which cached data it makes wrong and where it is refreshed.
- Check every retry in the diff: it is on a read, or the write carries an idempotency key.
- For every new or changed sensitive response, state its `Cache-Control` header.

Sources

  1. OpenAPI Initiative, OpenAPI Specification v3.1.1, 2024
  2. JSON Schema, object, docs
  3. Angular, Reactive data fetching with httpResource, docs
  4. React, You Might Not Need an Effect, docs
  5. MDN, AbortController
  6. MDN, Cache-Control header
  7. MDN, HTTP caching
  8. MDN, Idempotent
  9. MDN, 409 Conflict
  10. Angular, Intercepting requests and responses, docs
  11. web.dev, Keeping things fresh with stale-while-revalidate
  12. web.dev, Prevent unnecessary network requests with the HTTP Cache
Share this article