Deco Cache
Deco has three caching layers, each operating at a different level. Apply all three together for lowest possible latency.
Layer 1 — Loader Cache (server-side, per loader)
Caches the data fetched by a loader before the section is rendered.
Sync loaders do not need cache. A sync loader runs entirely in memory with no I/O — it is already instant. Adding
cacheto it only introduces overhead (key computation, serialization, cache lookup) and makes the code harder to reason about. Cache applies exclusively to async loaders that perform network calls or other I/O.
Configuration
cache and cacheKey must be exported from a dedicated loader file inside the loaders/ folder.
They never work when defined inline inside a section file. If the loader is written directly inside the section, extract it to loaders/ first, then add the cache config there.
// loaders/myLoader.ts ✅
export const cache =
// "no-store" | "no-cache" | "stale-while-revalidate" | { maxAge: number }
export const cacheKey = (props, req, ctx) => string | null
// sections/MySection.tsx ❌ — cache config here is ignored
export const loader = ...
export const cache = "stale-while-revalidate"; // never do this
Cache modes
| Mode | Behavior | When to use |
|---|---|---|
"no-store" (default) |
Disables cache; also prevents CDN section caching | Carts, sessions, user-specific data |
"no-cache" |
Always fetches fresh, but section can still be CDN-cached | Loader must be fresh but section is safe to cache |
"stale-while-revalidate" |
Returns cached data, revalidates in background (default TTL: 60s) | Best default for public, read-mostly loaders |
{ maxAge: number } |
SWR behavior with a custom TTL in seconds | Public data that is stable for longer periods (e.g. { maxAge: 60 * 60 } for 1 hour) |
Default TTL: 60 seconds (override via CACHE_MAX_AGE_S env var or per-loader { maxAge }).
Note:
export const maxAge = Nas a separate export is not a supported API and will be silently ignored. Always useexport const cache = { maxAge: N }to set a custom TTL.
Cache key rules
The final key is composed of: resolver name + return value of cacheKey.
- Never use the raw
req.urlorurl.hrefas the key. Real URLs carry tracking params (utm_*,gclid,fbclid, session tokens, etc.) that make every request look unique, effectively disabling the cache. - Build the key from props, not from the URL. Use the loader
propsas the primary source of truth — they already represent the canonical inputs the framework parsed. - If you do use the URL, reconstruct it with only the params you need (see examples below).
- Include segmentation traits (locale, currency, segment token) when they affect the result.
- Return
nullto disable caching for a specific invocation (e.g. logged-in user).
Examples
// Public loader — key built from props only (safe, no URL noise)
export const cache = "stale-while-revalidate";
export const cacheKey = (props: { slug: string }) => props.slug;
// Segment-aware — bypass for logged-in users
export const cache = "stale-while-revalidate";
export const cacheKey = (_props: unknown, _req: Request, ctx: AppContext) => {
if (!ctx.isAnonymous) return null;
return ctx.segment?.token ?? "anonymous";
};
// Multi-prop key — compose from props, not from req.url
export const cache = "stale-while-revalidate";
export const cacheKey = (props: { category: string; page: number }) =>
`${props.category}:${props.page}`;
// Enforce longer cache time (1 hour) — SWR mode is implicit
export const cache = { maxAge: 60 * 60 };
export const cacheKey = (props: { category: string }) => props.category;
// If you must use the URL, reconstruct it with only known-safe params
export const cache = "stale-while-revalidate";
export const cacheKey = (props: { slug: string }, req: Request) => {
const { origin, pathname } = new URL(req.url);
// Append only the props-derived params — never spread the original search string
return `${origin}${pathname}?slug=${props.slug}`;
};
// Opt-out (cart, session)
export const cache = "no-store";
Invalidation
Automatically invalidated on every new deployment. No manual invalidation before TTL expiry.
Layer 2 — Stale Edge Cache (CDN, per section)
Caches the fully rendered section HTML at the CDN edge. Sections load asynchronously; those that exceed the render time budget are served as skeletons on first load, then fetched and patched from the CDN.
Default TTL headers
| Directive | Value | Meaning |
|---|---|---|
s-maxage |
60s | CDN holds the section for 60 seconds |
stale-while-revalidate |
3600s | Serve stale for up to 1 hour while revalidating |
stale-if-error |
86400s | Serve stale for up to 24 hours on origin errors |
Cacheability rule
A section is cached at the CDN only if all of its loaders are configured for caching (i.e. none use "no-store"). A single uncached loader makes the whole section uncacheable.
Async rendering
- Enabled by default for all sections.
- To disable: turn off Optimization in section properties in the Admin.
- Off-screen sections are deferred until the user scrolls near them.
Layer 3 — HTML Page Cache (CDN, full page)
Caches the fully assembled page HTML at the CDN edge. A cache hit means zero server involvement.
Minimum versions required
| Package | Minimum version |
|---|---|
deco |
1.199.0 |
apps (VTEX sites) |
0.153.0 |
How it works
This layer is available for VTEX sites only. When the site is on the required versions, the deco runtime automatically emits Cache-Control: public on cacheable HTML responses. The CDN (Cloudflare) is already configured globally to cache responses that carry this header — no per-site CDN changes are needed.
Default Cache-Control for cached pages:
public, max-age=90, s-maxage=90, stale-while-revalidate=3600, stale-if-error=86400
Anonymous requests from logged-out users with no active promotions or price tables are cached. Everything else (logged-in users, personalized segments) receives no-store.
How to enable
Update deps in deno.json:
{
"imports": {
"deco/": "https://denopkg.com/deco-cx/deco@1.199.0/",
"apps/": "https://denopkg.com/deco-cx/apps@0.153.0/"
}
}
No CDN configuration, no env vars needed. DECO_PAGE_CACHE_CONTROL is still supported to override the default Cache-Control value if needed.
Verify
# Anonymous user — expect cacheable header
curl -sI https://www.site.com.br/ | grep -i cache-control
# → cache-control: public, max-age=90, s-maxage=90, stale-while-revalidate=3600, stale-if-error=86400
# Logged-in user — expect no-cache
curl -sI https://www.site.com.br/ \
-H "Cookie: VtexIdclientAutCookie=somevalue" \
| grep -i cache-control
# → cache-control: no-store, no-cache, must-revalidate
Common issues
| Symptom | Likely cause |
|---|---|
No Cache-Control: public on anonymous requests |
apps below 0.153.0; active segment (campaigns/priceTables/regionId); or Set-Cookie present in the response (any loader or middleware setting a cookie forces the runtime to skip caching) |
| Cloudflare still serving MISS | Response missing Cache-Control: public |
⚠️ Do NOT use a site-level _middleware.ts to strip cookies
Adding a routes/_middleware.ts that strips cookies and overrides no-store is dangerous: it can re-enable caching for responses intentionally marked non-cacheable (e.g. users with non-default price tables), serving wrong prices or promotions to other users. If a site already has such a middleware, remove it.
How the layers interact
Request
└─ HTML Page Cache (CDN) ──── HIT → serve page, done
└─ MISS
└─ Stale Edge Cache (CDN, per section) ── HIT → serve section HTML
└─ MISS
└─ Loader Cache (server) ── HIT → serve cached data, render
└─ MISS → fetch from upstream API, render, populate caches
Agent workflow — improving cache on a store
When auditing or improving a store's cache:
- Find all async loaders — look for files in the
loaders/folder exporting aloaderfunction that performs I/O (API calls, fetches). Skip sync loaders that only transform props — caching them is actively harmful. Also skip anyloaderdefined inline inside a section file — cache config does not work there and the loader must be extracted toloaders/before cache can be applied. - Check for missing cache config — any async loader without
export const cachedefaults to"no-store"and blocks CDN section caching. - Classify each loader:
- Public / read-mostly →
"stale-while-revalidate"or{ maxAge }+ a stablecacheKey - Must-be-fresh but section is safe →
"no-cache"+cacheKey - User-specific / session →
"no-store"(nocacheKeyneeded)
- Public / read-mostly →
- Write
cacheKeyfrom props, not from the URL. The URL contains tracking params and query strings that vary per visitor and destroy cache hit rates. Build the key by composing only the props fields that affect the result. Returnnullfor authenticated contexts. - Verify CDN cacheability — after configuring loaders, check that sections whose loaders are all cached are actually being served from the edge.
- Check HTML page cache eligibility — read
deno.jsonand confirmdeco≥1.199.0andapps≥0.153.0. If not, update deps. Then verify withcurl -sI https://www.site.com.br/ | grep -i cache-control.
Priority order
- Add
cache+cacheKeyto every public async loader missing them. - Fix loaders using
"no-store"unnecessarily (blocking section CDN caching). - Audit existing
cacheKeyimplementations — replace any that usereq.urlorurl.hrefdirectly with prop-based keys. - Tune TTLs: shorter for volatile data, longer (5–30 min) for stable catalog data.
- Enable HTML page cache: check
deno.jsonfordeco@1.199.0+apps@0.153.0and update if needed.