Public beta — not for production use. Data may be wiped at any time. Questions? Contact us.
Documentation menu

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 Markdown

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" 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: 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:

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:

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) carry the same doctrine with worked examples of a good (reference-data) and bad (record-read) declaration.