Caching
By default a page's @server loader runs on every request and the page is
rendered fresh each time. For pages whose content is the same for everyone and
changes rarely — a marketing page, a docs article, a blog post — that work is
wasted. Pyxle's page cache stores the rendered HTML and serves it back
without re-running the loader or the SSR render, then refreshes it in the
background when it goes stale.
The one rule: only cache pages that render no per-user data. A cached render is shared byte-for-byte with every visitor. If a page embeds a logged-in user's name, a CSRF token, or anything request-specific, do not cache it — you would serve one user's page to another. Caching is always opt-in, exactly so this stays your deliberate choice.
Making a page cacheable
Return a {"data": ..., "revalidate": <seconds>} envelope from your loader
instead of a plain dict. data is the props your component receives (exactly
as before); revalidate is how many seconds the cached render stays fresh.
@server
async def load_post(request):
post = await fetch_post(request.path_params["slug"])
return {
"data": {"post": post},
"revalidate": 60, # cache this render for 60 seconds
}export default function Post({ data }) {
return <article>{data.post.body}</article>; // `data` is the inner dict
}That's it. The first request renders and stores the page; requests within the next 60 seconds are served from the cache — no loader, no Node render.
The envelope is recognised only in its exact two-key shape (data and
revalidate, nothing else). A normal loader that happens to return keys named
data or revalidate is never mistaken for a cache directive.
Pages without a loader
A page with no @server loader can still opt into caching with a module-level
CACHE directive in its Python section:
CACHE = {"revalidate": 3600} # cache this static page for an hourexport default function About() {
return <main><h1>About Us</h1></main>;
}revalidate is the freshness window in seconds, exactly as in the loader
envelope (0 means "serve cached but re-render every request"). It is validated
at compile time: an invalid CACHE directive (non-numeric or negative value, or
a malformed shape) is reported as a compile diagnostic and the page is
treated as uncached — whether that diagnostic blocks the build depends on
your strict/non-strict diagnostic mode. If a page declares both a loader
envelope and a CACHE directive, the loader's revalidate wins.
Incremental regeneration (stale-while-revalidate)
When a cached page passes its revalidate window, the next request still
gets the cached (stale) bytes immediately — no one waits for a re-render — and a
single background re-render refreshes the cache for everyone after it. This is
incremental static regeneration (ISR): fast responses, fresh-enough content, and
never a thundering herd (only one refresh runs per page at a time, even under
load).
revalidate: 0 is valid and means "serve the cached copy but re-render on every
request" — useful when you want to absorb bursts without ever serving content
older than one render.
Static pre-rendering (pyxle build --static)
Pages with no @server loader and no dynamic route parameters render the same
HTML for everyone, every time. pyxle build --static renders them once at
build time and stores the result, so the first request after a deploy is
already a cache hit — no cold SSR render, even for the very first visitor.
pyxle build --staticThis pre-renders every loader-less, non-dynamic page into the build output's
prerendered/ subdirectory (dist/prerendered/ by default; dist here means
the configured build/output directory, i.e. pyxle build -o/--out-dir);
on startup pyxle serve warms its page cache from that directory. Pages with a
loader (or a {param} route) are skipped — they still render live and cache at
runtime as before.
A static page's layout loader still runs at build time — its result is baked
into the pre-rendered HTML, so a layout loader used by static pages should return
the same data for everyone (global status, navigation), not per-user state. Build-
time loaders run with the same plugin context a request has, so a layout (or page)
loader that reads from a plugin like pyxle-db pre-renders correctly — the static
builder opens your plugins' connections at build time, just as a server would.
Pre-rendered entries have no expiry until you
cache.invalidate(...) the route. With the default in-memory backend they are
re-warmed from the new dist/prerendered/ on every restart; with a shared
file/redis backend, warmed copies persist in that store, so if a later deploy
stops passing --static (or adds a loader to a previously-static route), flush
the cache or invalidate those routes — replacing dist/ does not clear them.
Invalidating the cache
When the underlying data changes — you publish a post, edit a page — purge the
cached render so the next request re-renders immediately instead of waiting out
the revalidate window:
from pyxle import cache
@action
async def publish_post(request):
body = await request.json()
await save_post(body)
await cache.invalidate(f"/posts/{body['slug']}") # drop that page's cache
return {"ok": True}await cache.invalidate(path)purges one route's cached render. ReturnsTrueif something was cached,Falseotherwise — safe to call either way.await cache.invalidate_all()purges every cached render.
How it interacts with the edge cache
A route listed in your pyxle.config.json cache
block (the edge cache, which sets Cache-Control: public, s-maxage=… for a
CDN) is also served from the server-side page cache automatically, using that
same TTL — you don't need to repeat yourself. When both apply, a loader's
revalidate wins over the edge s-maxage.
Cached page responses carry a strong ETag, so a conditional request
(If-None-Match) gets a 304 Not Modified, and an x-pyxle-cache response
header reports HIT, STALE, or MISS for debugging.
Client navigation cache
Separately from the server-side page cache, the browser keeps a per-URL navigation cache of loader payloads so back/forward navigation is instant. Its lifetime mirrors a page's real cacheability:
- A page with a
cacheentry (orCACHEdirective) reuses that TTL. - A dynamic page — a
@serverloader with no declared cache lifetime — is not navigation-cached by default (TTL0), so a fresh navigation always refetches and a just-made mutation is visible immediately rather than hidden behind a stale window. Opt in by giving the route acacheentry. - A static, loader-less page uses the client default (2 minutes).
See Navigation cache TTL for the full client-side details.
Where the cache lives
The cache is enabled automatically for production serves (pyxle serve) and
disabled in pyxle dev so a cached render never hides an edit while you work.
Choose where rendered HTML is stored with the PYXLE_PAGE_CACHE_BACKEND
environment variable:
PYXLE_PAGE_CACHE_BACKEND |
Stores in | Use when |
|---|---|---|
memory (default) |
bounded in-process memory (LRU) | single process, or any deploy where per-worker caches are fine |
file |
local disk (PYXLE_PAGE_CACHE_DIR) |
one host, multiple workers sharing a cache that survives restarts |
redis |
shared Redis (PYXLE_PAGE_CACHE_REDIS_URL) |
multiple hosts, or cross-worker invalidation |
off |
nothing — caching disabled | you want it off in production (none and disabled are accepted aliases for off) |
The in-memory backend is bounded by entry count (PYXLE_PAGE_CACHE_MAX_ENTRIES,
default 512) and total body bytes (PYXLE_PAGE_CACHE_MAX_BYTES, default 64 MiB)
with LRU eviction, so it never grows without limit. The Redis backend needs the
optional extra: pip install 'pyxle-framework[redis]'.
Invalidation and workers. With the in-memory or file backend under
pyxle serve --workers N, each worker keeps its own store, so
cache.invalidate(...) reaches the worker that handled the action; entries
still expire everywhere on their own via revalidate. The Redis backend is
shared across every worker and host, so an invalidation fans out to all of them
— choose it when you need cross-worker purges.
When *not* to cache
- Pages that show the signed-in user's data, a per-user CSRF token, or anything
that varies by request. Leave these as plain loaders (
return {...}), and they are never cached. - Pages that must always reflect the absolute latest data with zero staleness —
use a plain loader, or
revalidate: 0pluscache.invalidate(...)on every write.
Query strings: the cache keys on the route path only, so a request that carries a query string (
/search?q=…,?page=2) is always served live — never cached and never served a cached entry. A page whose content varies by query is therefore never collapsed onto one shared render.
Next steps
- Load data: Data Loading
- Mutate data: Server Actions
- Configure the edge cache: Configuration