Engineering noteResearch & Engineering
Part of: Astro on Workers
Astro Server Islands: Cache Age Is Not Data Age
We keep Astro pages static while handling old measurements, failed source reads and island requests that never replace the fallback.
The programme
On this page
- Build the fallback as part of the page
- Separate source failure from fragment failure
- Judge the source’s clock, then the cache’s clock
- Cache an empty list, retry an unreadable source
- Identify which cache holds which object
- What the production requests establish
- Decide whether a server island fits
- Reproduce the fallback decision
- Configuration examined
- Continue in the Astro series
- Frequently asked questions
A server island can return new HTML containing an old measurement. It can also answer successfully with a source-failure message, or fail before it replaces anything on the page. Those cases need different behaviour. A short cache lifetime alone cannot tell a reader whether a number is current.
We use Astro server islands on prerendered programme pages to update session counts and upcoming sessions between builds. The design has three decisions: what the build leaves on the page, how the island judges its source data, and when the browser replaces the fallback. This article follows all three, then separates the caches that can make a reading look newer than it is.
Our examples are the programmes overview, TEEI Language, TEEI Mentorship and Skills Academy. We examine their implementation and production responses recorded on 15 October 2026. The Astro on Workers homepage case follows a separate, later implementation through the handoff to an open browser; the Cloudflare overview describes the wider platform.
Built through volunteer contributions. TEEI’s software platforms are built through volunteer work, with NOK 0 spent on paid software development. Infrastructure and third-party services are separate from that figure.
Build the fallback as part of the page
The project uses output: 'server', and each programme page opts into build-time rendering with export const prerender = true. We serve its HTML through Workers Static Assets. Our run_worker_first = true setting invokes the Worker even for an asset hit; the adapter can then return the file. Worker-first and assets-first routing explains that choice.
At build time, we read the programme statistics and remove the fields labelled “now”. The remaining totals, with their measurement time, become known:
Adapted for publication.
---
const known = withoutNow(await readRecord());
---
<LiveRecord server:defer variant="dateline" known={known}>
<RecordView slot="fallback" variant="dateline" reading={known} />
</LiveRecord>
Astro puts the fallback into the page’s HTML. It renders the deferred component through a separate, never-prerendered /_server-islands/[name] route. Readers can receive the shell before that request finishes.
The duplicated known is deliberate. The fallback slot belongs to the shell: Astro excludes it from the slots sent to the island. Passing the same data as a prop lets the island use it when a source read fails. Writing <slot name="fallback" /> inside the island would retrieve nothing.
A fallback figure depends on the build obtaining a valid reading. If that read fails, we have no figure to show until an island supplies one. We encountered the same dependency on the homepage: a dry run on 11 October 2026 retained a 27 September figure. We supplied that build with a deploy snapshot and added a test that it uses the snapshot when the source is unreachable. A fallback is its own data path, with a failure case of its own.
Separate source failure from fragment failure
This is the programme-statistics island:
Adapted for publication.
---
interface Props {
variant: 'dateline' | 'slip';
known: Reading | null;
}
const { variant, known } = Astro.props;
const reading = await readRecord();
Astro.response.headers.set(
'Cache-Control',
reading ? 'public, max-age=60, stale-while-revalidate=300' : 'no-store',
);
---
<RecordView variant={variant} reading={reading ?? known} />
A valid reading gets a cacheable HTML response. When the source returns no usable reading, we render known, or no figure if that is also absent, and send no-store. The failed source read does not become a reusable success in the cache.
The browser makes a different decision: whether it received a replacement fragment at all. Astro’s inlined function checks the status and content type, then reads the body before removing the fallback:
Adapted for publication.
async function replaceServerIsland(id, r) {
let s = document.querySelector(`script[data-island-id="${id}"]`);
if (!s || r.status !== 200 || r.headers.get('content-type')?.split(';')[0].trim() !== 'text/html') return;
let html = await r.text();
// Only after this succeeds does Astro replace the fallback.
// …
}
| What happens | What the reader sees |
|---|---|
| The island returns 200 HTML and its body can be read | The HTML the island rendered, including any source-failure explanation |
| The island returns another status or content type, or its request or body read fails | The fallback already in the page |
| The statistics source fails but the island returns 200 | The build’s reading, or no figure when neither reading exists |
This is why our islands return 200 when they can render a useful explanation of a source failure. Returning 503 would leave the shell’s fallback in place, hiding the explanation. HTTP status describes delivery of the fragment; its HTML describes the result of reading the source.
These failure paths come from code and tests; we did not observe a source outage or failed fragment replacement in the production measurements below.
Judge the source’s clock, then the cache’s clock
We accept finite, non-negative totals and a parseable source timestamp. Invalid bodies, unsuccessful responses and errors return null. A bounded source read prevents the island waiting indefinitely; a module-level memo lets one isolate reuse a valid reading:
Adapted for publication.
let memo: { at: number; reading: Reading } | null = null;
const MEMO_MS = 60_000;
export async function readRecord(timeoutMs = READ_DEADLINE_MS): Promise<Reading | null> {
const nowMs = Date.now();
if (memo && nowMs - memo.at < MEMO_MS) return toReading({ ...asBody(memo.reading) }, nowMs);
try {
const r = await fetch(UPSTREAM, {
headers: { accept: 'application/json' },
signal: AbortSignal.timeout(timeoutMs),
});
if (!r.ok) return null;
const reading = toReading(await r.json(), nowMs);
if (reading) memo = { at: nowMs, reading };
return reading;
} catch {
return null;
}
}
toReading() does more than validate the body. It compares the source’s measurement time with the current clock. We remove the “now” cells when that measurement is over fifteen minutes old or more than one minute ahead, while keeping valid totals with their measurement time. Missing or invalid in-progress and completed-today counts remove those cells; an invalid or past next-start time removes the next start.
The memo’s age answers how long this isolate has held a reading. The source timestamp answers when the measurement was made. Even a memo filled a second ago may contain expired live cells, so we run the projection again on each memo hit. On a miss, the code uses the clock sampled before fetch for both the freshness decision and the memo’s start time.
That check ends at rendering. A browser reusing the island’s HTML under max-age=60, stale-while-revalidate=300 does not rerun it. Likewise, stripping “now” from the build’s known avoids giving those cells the lifetime of the shell, but does not make the remaining totals recent.
In a production capture at 11:18 UTC on 15 October 2026, the programmes-overview island showed “Measured 11:14 UTC”; the public statistics fetched in the same minute carried 11:14:05 UTC. That capture relates one rendered value to its source timestamp.
Cache an empty list, retry an unreadable source
Upcoming Skills Academy sessions need a different contract. An empty list is a successful answer, and must remain distinguishable from a list we could not read:
| Outcome | Meaning | HTML cache policy |
|---|---|---|
matches | Valid upcoming sessions remain after filtering | 60 seconds fresh, then up to 300 seconds stale while revalidating |
none_open | A valid source list has no sessions to show | The same policy |
source_unavailable | The source failed or returned an invalid body | no-store |
Verbatim excerpt.
const reading = await readSessions();
Astro.response.headers.set(
'Cache-Control',
reading.outcome === 'source_unavailable' ? 'no-store' : 'public, max-age=60, stale-while-revalidate=300',
);
We drop malformed rows individually. From the valid source body, we remove meetings that have started, keep the earliest meeting per offering, sort by start time and show at most five offerings. Repeating that projection on a cached body can remove a session that has started; it cannot discover one published after the body was fetched.
This reader keeps the raw body in two places: a memo per isolate and the Cache API in the data centre, each with a 60-second lifetime. We include the source in the cache key and cache only bodies we can parse. We await a Cache API write when available and ignore write failures. A Cache API hit starts a new memo lifetime without carrying the entry’s age along, so the two lifetimes can accumulate before the rendered HTML’s policy adds another.
At 11:18 UTC on 15 October 2026, the source held an empty list and the island answered none_open, reporting that no session was open for sign-up. If the fragment never arrives, the fallback still offers links to view sessions and sign up, or sign in.
The reader tests supply an error status, a foreign body and a thrown TimeoutError; each must produce source_unavailable, and the next read must ask the source again. Those tests establish classification and failure eviction. They do not establish that the deadline actually aborts a request, and we do not check either island’s response headers in a test. The programme-statistics reader has no corresponding test for its timeout or no-store failure branch.
Identify which cache holds which object
HTML and source data have separate lifetimes. The shell is a file at the edge. The island is a rendered response the browser may reuse. The memo and Cache API hold data inside the Worker. A hit in one says nothing about the age of another.
Our shell responses carried cf-cache-status: HIT and cache-control: public, max-age=0, must-revalidate. The island responses carried public, max-age=60, stale-while-revalidate=300, x-robots-tag: noindex and no cf-cache-status. In this configuration, we set the island policy for browser reuse; we did not measure that reuse. The production requests did not demonstrate an edge cache for the islands.
Two settings matter here. Workers Caching honours the Worker’s response policy when enabled in its configuration; setting Cache-Control alone does not enable it. Separately, Astro supports an adapter cache provider. With @astrojs/cloudflare 14.0.0 or later, cacheCloudflare() sets Cloudflare-CDN-Cache-Control and Cache-Tag and enables Workers Caching. Neither was configured in the 15 October case examined here.
We left the provider off on 14 October 2026 because its default affects the whole site: the adapter adds Cloudflare-CDN-Cache-Control: no-store to server-rendered responses that lack that header. The guard we read in adapter 14.2.4 checks that specific header; an island’s plain Cache-Control would not prevent the default. Enabling a provider therefore needs a decision about the site’s other responses, too.
The Cache API is a separate mechanism, local to one data centre, with no read-through, request collapsing or tiered cache. Within one isolate, our two overview islands can reuse a completed memo reading. Concurrent misses can still reach the source before either fills it.
What the production requests establish
On 15 October 2026, between 10:50:00 and 10:51:28 UTC, we sent twenty curl requests to each surface from Oslo, with three seconds between rounds. The prerendered shells had median first-byte times of 46 to 49 milliseconds and all were edge cache hits. The islands’ medians were 55 to 60 milliseconds. The public-statistics source had a median of 166 milliseconds; the Skills Academy list, 215 milliseconds, with one source read taking 1,858 milliseconds.
time_starttransfer, not full transfer; for the shells, that first byte can be HTTP 103.These were separate, sequential requests. Their time_starttransfer values do not form a browser waterfall or measure when a figure became visible. In a header capture at 11:18 UTC the same day, the shells sent HTTP 103 Early Hints before the final 200; the islands and sources sent none. curl records time_starttransfer when the first response byte arrives, and in a local check with curl 8.23.0 that was the 103, not the 200. The shell medians can therefore time the 103 rather than the final HTML. We kept only the cache headers from the timed rounds, so we cannot say which of those requests received a 103. Astro waits for the island body before replacing the fallback, so first-byte timing alone cannot time that replacement.
The lower island medians are consistent with reuse of source readings. Without server-side hit instrumentation, we cannot attribute an individual request to the memo, Cache API or a fresh source read. These observations establish response behaviour in one window from Oslo, without measuring Core Web Vitals, browser cache reuse or source-outage behaviour.
Decide whether a server island fits
Use this pattern when a page has useful build-time content and a small public fragment can arrive separately. Check four constraints before adopting it:
- The fallback must carry the initial experience. Clients that do not execute JavaScript see it permanently. If a figure must appear there, the build must obtain it or the page must render it on demand.
- Props affect the request method. In the Astro 7.0.6 and 7.2.10 runtimes we read, a relative island URL shorter than 2048 characters uses GET, with a preload link in the page head. At 2048 characters or more it uses POST, which the browser does not cache. Astro’s guide describes the threshold in bytes. Our captured URLs were 96 to 196 characters; we did not measure the preload’s effect on visibility. Keep props small when browser caching matters.
- A cached shell can outlive a deployment. Astro generates a key per build unless one is supplied. Old HTML may request an island the new build cannot decrypt or no longer maps, leaving the fallback in place. Follow Astro’s advice on reusing a key when caching shells across builds.
- Public caching needs public content. Our fragments are the same for every reader. For personal responses, choose an explicit private cache policy. Workers Caching can otherwise cache a 200 heuristically when no cache headers are present.
An island also adds a Worker request. On our on-demand trust page, twelve islands let individual checks arrive separately; ten curl rounds on 15 October 2026 between 10:55:14 and 10:56:44 UTC gave the page a median of 124 milliseconds and the 120 island responses a median of 127 and p90 of 399. Deferral and prerendering are separate choices, and those separate timings do not measure the combined page experience.
Reproduce the fallback decision
In your own Astro 7 project with @astrojs/cloudflare, create ./src/pages/island-check.astro:
Example for your own project.
---
import Clock from '../components/Clock.astro';
export const prerender = true;
---
<Clock server:defer>
<p slot="fallback">Reading the clock…</p>
</Clock>
Create ./src/components/Clock.astro with fail initially set to false:
Example for your own project.
---
const fail = false;
await new Promise((resolve) => setTimeout(resolve, 3000));
Astro.response.status = fail ? 503 : 200;
Astro.response.headers.set('Cache-Control', 'public, max-age=60');
const renderedAt = new Date().toISOString();
---
<p>Rendered at {renderedAt}</p>
Build the project, run it with wrangler dev and open it with the browser cache disabled. The fallback should remain during the wait, then give way to the HTML. Change fail to true, rebuild and restart: the component still renders a body, but its 503 should leave the fallback in place. This is a reproduction for your project, not an additional production measurement.
To inspect an existing GET island, take its URL from <link rel="preload" as="fetch"> in the page head and read the headers with curl -s -D - -o /dev/null '<url>', converting HTML’s & back to &. Check cache headers separately from the source’s measurement timestamp. Neither substitutes for watching the fragment replace its fallback in a browser.
Configuration examined
As read on 15 October 2026.
| Setting | Value |
|---|---|
| Astro | 7.0.6 identified in production HTML; runtime also read in 7.2.10 |
@astrojs/cloudflare | 14.1.1 alongside Astro 7.0.6 in the lockfile; handler also read in 14.2.4 |
| Wrangler installed in the repository | 4.107.0 |
| Output | 'server', with prerender = true on the programme pages |
| Prerender environment | Node; the deployed Worker handles requests in workerd |
| Static Assets | ASSETS, with run_worker_first = true |
| Workers Caching and Astro cache provider | Neither configured |
| Captured islands in production HTML | Programmes overview 2, TEEI Language 1, TEEI Mentorship 1, Skills Academy 1, trust page 12, homepage 0 |
| Source reads | Explicit deadlines and fallback when the source cannot answer |
| Programme and Skills Academy memos; Skills Academy Cache API entry | 60 seconds each |
Continue in the Astro series
Follow the homepage case through a fragment’s handoff to browser updates. For the response contract behind that connection, continue with handling WebSocket upgrades before Astro.
Frequently asked questions
Can a cached server island show an old measurement?
Yes, so we check the age of the measurement, not only the age of the cache. Each time we render the island, we compare the source timestamp with the current time and remove the “now” cells when the measurement is over fifteen minutes old or more than one minute ahead; the totals keep their measurement time. A browser that reuses island HTML it already holds does not run that check again.
What do readers see if a server island request fails?
The fallback stays. We replace the fallback only when the island answers with status 200, the content type text/html and a body we can read. With anything else, we leave the fallback on the page.
Is the fallback slot sent to the server island?
No. We render the fallback into the page, and use Astro to pass every other slot to the island, never the fallback. If your island needs the same data on failure, pass it as a prop as well.
Does Cloudflare cache a server island response at the edge?
In the configuration examined on 15 October 2026, we used no Astro cache provider and had Workers Caching off. A Cache-Control header alone does not turn Workers Caching on, so we set it for browser reuse. Our captured island responses carried no cf-cache-status header; we did not measure browser cache reuse.