---
title: "Browser caching for flows and nodes"
description: "Declare a read-only flow cacheable so browsers reuse the response instead of re-invoking it, declare a node cacheable for direct owner-credentialed callers, and understand exactly what that promise does and does not cover."
category: guide
surfaces: [cli, http-api]
related: [concepts/execution-model, guides/publish-a-flow, guides/build-a-client-sdk, reference/axiom-yaml, reference/http-api]
last_reviewed: 2026-08-19
---

# Browser caching for flows and nodes

A unary flow or a node can declare itself **cacheable**. Either way, a
declared target gets a `GET` route alongside its normal `POST` one, with
`max_age_seconds` of freshness and no request at all inside that window —
no network round trip, no execution, no metering. The two declarations
reach different callers, though: a cacheable **flow**'s `GET` is what a
generated client emits for a browser app, so it is the browser's own HTTP
cache that ends up serving the reuse. A cacheable **node**'s `GET` reaches
only a DIRECT, owner-credentialed caller invoking that one node by
itself — see ["Nodes: a wire-level declaration, not a client one"](#nodes-a-wire-level-declaration-not-a-client-one)
below. This page covers how to declare either, what the response actually
promises, and the two things you own once you turn it on: staleness and
revocation.

## Declare a flow cacheable

Add a top-level `cache:` block to `flow.yaml`:

```yaml
name: me/product-catalog
version: 1.0.0
cache:
  max_age_seconds: 300
nodes:
  - alias: lookup
    package: me/catalog@1.0.0
    node: FetchProduct
```

Presence of the block is the declaration — there is no separate on/off flag.
`max_age_seconds` is required and must be positive; a `cache: {}` block with
no TTL declares nothing and is rejected at `compile`.

## Nodes: a wire-level declaration, not a client one

A node in a package declares cacheable the same way, in `axiom.yaml`:

```yaml
nodes:
  - name: FetchProduct
    cache:
      max_age_seconds: 300
```

The declaration is real: it gates the same `GET /v1/nodes/invoke` wire
route (the rest of this page's directive posture, revalidation economics,
and mis-declaration lint all apply to it identically), and `axiom
validate`/`axiom push` check it the same way `flow.yaml`'s block is
checked. What it does **not** do is change how any generated client calls
the node. No generated client (browser or otherwise) ever emits
`GET /v1/nodes/invoke`, even for a declared-cacheable node — a generated
client has no way to know, at BUILD time, which credential context each of
its callers will run under, and `GET /v1/nodes/invoke` is only usable by
the app's own owner-scoped credential invoking that node directly. A node
you call through a compiled flow always rides that flow's own route
(`POST`, or `GET` if the FLOW itself is declared cacheable); this
declaration is for a caller reaching the node on its own, directly, with
`curl` or a script, using the credential that owns it.

Presence of the block is still the declaration, `max_age_seconds` is
still required and positive, and `cache: {}` with no TTL is still rejected
the same way — at `push` this time, not `compile`.

Two categories are refused outright, at every compile and every push, not
just the first time you declare: flows with a human-in-the-loop pause, and
`pipeline_mode` (streaming) flows or nodes. Neither has a single stable
response body to hand a cache — a HITL pause means the browser would cache
whichever answer happened to be sitting in front of the pause, and a stream
has no one body at all.

## The directive posture, and what it actually promises

A cacheable target's `GET` response carries exactly:

```text
Cache-Control: private, max-age=<your declared TTL>
ETag: "<hash of this response>"
Vary: Authorization
```

`private` means the browser's own cache, not a shared proxy or CDN — the
response carries the caller's `Authorization` header, and `Vary` partitions
the cache by it, so two callers (or the same caller with a different token)
never share a cached entry. Axiom never sends `public`, `s-maxage`, or
`must-revalidate`: those three directives specifically re-enable *shared*
caching of a credentialed response, which is the one thing this design does
not do.

Inside the freshness window, the browser serves the cached response and
Axiom never sees the request: no execution, no metering, unmetered by
construction. After the window, the browser sends `If-None-Match` with the
old `ETag`. Axiom has no server-side cache to consult, so answering that
request means a **full re-execution** — the platform has no other way to
know whether the answer changed. If the hash matches, you get a `304` with
no body (you saved the transfer, not the execution); if it changed, a fresh
`200`. Size your `max_age_seconds` around this: a short TTL buys you little
because you pay full execution cost on every revalidation anyway, and a very
long TTL buys real savings at the cost of staleness you own.

## Declare cacheable only for reads nothing else depends on

`cache:` is an author's promise, not something the platform can check. Axiom
has no purity metadata for node code — a node is an arbitrary, sandboxed
program with unrestricted outbound access — so nothing here verifies that a
declared target is actually read-only. If you declare a flow cacheable and
one of its nodes has a real side effect (writes a row, sends an email, calls
a paid API), that side effect **silently does not happen** on every cache
hit, because a cache hit never reaches Axiom at all.

`axiom flow compile`/`axiom push` run a heuristic advisory check for this:
a declared-cacheable target whose node names look like a write (`Create…`,
`Insert…`, `Update…`, `Delete…`, `Execute…`, `Send…`, `Post…`) prints a
warning. It never blocks — the check is a naming heuristic, not proof, and a
correctly-named write and a wrongly-named read both compile fine. Treat the
warning as a prompt to double-check, not as ground truth in either
direction.

The safe category is **immutable, reference, or media reads**: a lookup
table, a product catalog entry, a static config value, an image or document
blob, anything where slightly-stale is a fine trade in exchange for zero
extra requests. The unsafe category is anything backing **live UI state** —
an inbox count, an order status, a dashboard number, or any read a user
expects to reflect what just happened. Never declare cache on those.

## Audience-gated apps: max-age is the revocation bound

For an app whose audience is not "everyone" (ADR-201), a browser's cache
does not know when a member is removed. A revoked member keeps serving
their own cached response, from their own browser, until it expires — the
platform has no way to reach into a browser and evict an entry. That means
**`max_age_seconds` is the actual bound on how long a revoked member can
still see a cached answer**, not just a staleness knob.

For an audience-gated app, keep the TTL short (60 seconds or less), or don't
declare cache at all on anything sensitive to membership. On any surface
where a user signs out or is removed, send `Clear-Site-Data: "cache"` so
their own next request starts from an empty cache rather than riding out
whatever TTL was in flight.

## What this does not cover

Non-browser SDKs (Go, Python, TypeScript-for-Node, C#, Java, Rust) always
send `POST` — there is no browser HTTP cache for them to benefit from, so
declaring cache changes nothing about how they call your flow. No
generated client, browser or otherwise, ever emits `GET` for a declared
node either, for the reason above: a node's `GET` route is for a direct,
owner-credentialed caller only. Streaming invokes and completed-run result
reads are their own separate shapes, out of scope here. The
`axiom-flow-authoring`, `axiom-package-authoring`, and
`axiom-client-authoring` Skills (shipped with the CLI; see
[Author with Claude Code](/docs/guides/author-with-claude-code)) carry the
same doctrine with worked examples of a good (reference-data) and bad
(record-read) declaration.
