Nested ISR (TTL coupling)
Page ISR (5s TTL) wrapping an <ISR ttl=30s>
fragment. The outer rerolls every 5 seconds; the inner cache
survives across multiple outer cycles because its TTL is
longer than the outer's. Refresh repeatedly: the outer
timestamp ticks every ~5s, the inner stays frozen for ~30s.
Output
Outer (page ISR ttl=5s): 2026-05-03T10:35:42.299Z
Reading the cadences
- 0s: cold — both timestamps captured fresh.
- 0–4s: every refresh hits the page cache; both timestamps frozen.
- 5s: page cache expires → re-render. The outer timestamp updates. The inner ISR is consulted: age = 5s, TTL = 30s → HIT; the cached HTML from t=0 is reused. Outer page is re-stored.
- 10s, 15s, 20s, 25s: same pattern — outer ticks, inner stays at t=0.
- 30s: outer expired again, inner consulted at age=30s vs TTL=30s → EXPIRED → re-render with a fresh timestamp.
The inner cache pays for itself: between t=5s and t=29s, the slow function child runs zero times even though the outer page rerolls 5 times.
Anti-pattern: if you flipped the TTLs
(outer=30s, inner=5s), the inner would expire long before
the outer rerolls. Every outer-miss would also be an inner-
miss, so the inner cache would never hit — dead weight. The
interop matrix in plan/granular-isr-streaming/
flags this as the case to avoid.
Source
import { ISR } from "@bext-stack/framework/cache";
// Page-level ISR: 5 second TTL.
export const revalidate = 5;
export default function Page() {
const outer = new Date().toISOString();
return (
<div>
<p>Outer (page ISR ttl=5s): {outer}</p>
<ISR cacheKey="nested:inner" ttl={30}>
{async () => {
// Slow render — the value of the ISR cache. Skipped on
// page-cache hit; consulted on page-cache miss; only
// re-runs when its own 30s TTL expires.
await new Promise(r => setTimeout(r, 100));
return `<p>Inner (fragment ISR ttl=30s): ${new Date().toISOString()}</p>`;
}}
</ISR>
</div>
);
}