Browser caching for flows and nodes
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.
View as MarkdownA 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"
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:
name: me/product-catalog
version: 1.0.0
cache:
max_age_seconds: 300
nodes:
- alias: lookup
package: me/catalog@1.0.0
node: FetchProductPresence 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:
nodes:
- name: FetchProduct
cache:
max_age_seconds: 300The 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:
Cache-Control: private, max-age=<your declared TTL>
ETag: "<hash of this response>"
Vary: Authorizationprivate 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) carry the
same doctrine with worked examples of a good (reference-data) and bad
(record-read) declaration.