Skip to main content
TEEI in Practice
Engineering note · Research & Engineering · 15 October 2026 Building a Live Homepage with Astro and Cloudflare Workers

TEEI in Practice

Engineering noteResearch & Engineering

Part of: Cloudflare Workers

Building a Live Homepage with Astro and Cloudflare Workers

A local browser experiment found two ordering bugs in our activity line. We follow the handoffs between Astro, Workers and the visible text.

On this page
  1. What arrives before the activity line
  2. The fragment also starts the updater
  3. What a copied upgrade actually keeps
  4. A new fragment can contain an older reading
  5. Bring related reads together deliberately
  6. Check the value that reaches the reader
  7. Frequently asked questions

In the implementation we examined on 17 October 2026, the activity line on our homepage had two jobs: provide the first value when someone opened the page, and update it when another learning session completed while that person was still reading. The first value and the next update arrived through different parts of our application. Both eventually wrote to the same line of text.

Status on 22 October 2026. On 18 October we took the activity line out of the homepage hero in our source code. The component is still in the codebase, but no page mounts it. This article describes the implementation as we examined it on 17 October.

That small feature brings together much of our Astro website: a page response, a deferred render on a Cloudflare Worker, retained events in a Durable Object, and a browser client receiving updates over a WebSocket. The interesting decisions happen where one part hands its result to the next. A successful HTTP response or socket connection can leave those decisions untested.

This article follows that handoff through the implementation we examined on 17 October 2026 and an isolated Astro/workerd reproduction. It also explains two experiments behind our choices: what happens when we copy an upgrade response, and how a newly rendered fragment can contain an older source reading.

The website belongs to The Educational Equality Institute (TEEI). The activity feed describes completed sessions on the shared platform behind TEEI Language and TEEI Mentorship, two programmes with different purposes. Our Cloudflare Workers overview places the website alongside the rest of the 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.

What arrives before the activity line

Our first design prerendered the homepage. Astro emitted a page during the build; a later request could receive that HTML from the edge cache. Selected fragments used Astro’s server:defer to obtain their own values after the page arrived.

By the code revision examined on 17 October, the homepage had changed to on-demand rendering. A request can now produce its HTML, and a cache can reuse that response for later requests. Those are separate decisions. cf-cache-status: HIT tells us that a response was reused; it does not tell us whether a build or an earlier request produced the HTML.

We made the rendering change as part of a move towards one shared homepage snapshot. The documented reasons were to put values in the first HTML and to read display decisions at request time. In the version examined here, the request code reads the snapshot and passes it to the page’s sections, but the sections have not all switched their reads to it. The activity line still uses its deferred component. Passing a new prop through a component tree is one step in a migration; showing values from it is another.

For that deferred line, the first HTML contains a place where the component will go. In the code revision examined on 17 October, the homepage mounts it without a fallback slot:

Adapted for publication.

astro
<LiveLine server:defer lang={lang} />

An empty position before the fragment arrives differs from the fallback sentence the component can render later. We need to inspect both responses to know what the reader can see at each stage. Calling the page static, server-rendered or cached does not answer that question.

The request also needs to reach the intended handler. We handle the socket upgrade in our Worker entry before passing ordinary requests to Astro. Running the Worker before assets gives that entry a chance to answer; it does not mean every request passes through Astro middleware. Our routing investigation separates Cloudflare’s decision from the adapter’s choice between an asset and a render.

The fragment also starts the updater

The deferred component reads the latest retained event. It tries the ledger binding first, then a read on the website’s own origin. If neither supplies a usable event, the view renders its fallback sentence. It does not manufacture an activity record. The public event describes the programme, rounded duration and time, with limited geography. The ledger masks the country pair before storage; the public-feed article explains that boundary. The line reads the retained events, rather than a total of all sessions.

The component can return fallback HTML with a 200 even when it has no usable event. The browser can insert that view and run the updater delivered with it. If the fragment itself cannot be inserted, the original position remains and that updater has not been delivered. These failures affect both the first text and the path for later updates. The server-islands article follows the distinction between a source failure and a failed fragment.

There is another dependency in our activity component: its answer contains both the view and an inline script. That script restores browser state and opens the live connection. The code for future updates arrives with the first activity fragment. Keeping them together lets a component that renders nothing also send no updater, but it makes startup depend on the fragment completing.

Sequence diagram following one activity line. Page HTML contains a deferred slot; the island reads the ledger and returns its view and inline client. After insertion the client opens the socket, receives retained events and later events, and updates visible text.
Figure 1. The deferred implementation examined on 17 October 2026. The island response delivers the view and its updater together; the local reproduction checks the browser transitions. Source: TEEI, from the pinned activity-line implementation and the local Astro/workerd reproduction.

This changes the failure question. We must check what happens to the updater when the fragment never arrives, as well as what happens to its first sentence. A diagram with an island request and an independently starting socket would miss that dependency.

Once the client starts, several inputs can supply a value. The rendered view may already contain the latest event the server read. The client can restore an event from sessionStorage. The socket’s hello message contains retained events, from which the client selects the newest timestamp. Later event messages, or messages shared by another tab, can replace the line again.

These inputs share a destination, but they do not share a delivery schedule. The order in which they arrive does not establish which record is newest. A retained event, a restored browser value and a newly delivered event need an explicit rule for replacing one another.

We built a small Astro 7.2.10 application with the Cloudflare adapter and ran its generated Worker locally in workerd. It uses the captured client, a view adapted to synthetic English labels, and a local ledger we can control. We pause the fragment to inspect the first HTML, then release it and observe network frames and DOM text separately.

With the fragment held, there was no activity-line element and no socket. Releasing a successful response inserted either the fallback sentence or the retained event. The client then received hello, and a new event changed the visible line without a reload. Returning 503 left the initial position empty and opened no socket during the observation.

The ordering cases were more revealing. We gave three synthetic records increasing event timestamps and durations of 10, 20 and 30 minutes so we could identify them in the text. A newer server-rendered record was briefly replaced by older sessionStorage; the socket’s hello then restored the newer record. When we delivered an older event after the newest one, the line moved backwards and stayed there.

The cause was in the replacement rule. The client chose the newest event within hello.latest, but its display function did not compare an incoming value with the event already on screen. Selecting the newest item in one message does not order that message against all the other inputs.

For a separate local variant, we made the event time an explicit baseline. The initial server event sets that baseline; an absent event starts at negative infinity. Every later input uses the same acceptance rule before changing the DOM or storing a value:

Adapted from the proposed local correction; formatting and persistence are omitted.

ts
let visibleAt = initialEventTime;

function accept(event) {
  const nextAt = Date.parse(event.settledAt);
  if (!Number.isFinite(nextAt) || nextAt <= visibleAt) return;
  visibleAt = nextAt;
  show(event);
}

Initializing from the server matters. A guard that starts only after the first client message still allows stale storage to replace the newer server text. We also chose to keep the displayed record when timestamps are equal. That is a policy for one latest-activity line; it is not identity deduplication or a complete ordering of every event in a stream.

In the proposed variant, older storage never replaced the newer server text, and a late older event left the newest record visible. Storage and the custom event used by other page code also stayed unchanged for the rejected inputs. Conversely, a stored record newer than the server reading survived an older hello. The proposed variant orders all of those inputs against the same baseline; it does not rely on which path is fastest.

What a copied upgrade actually keeps

Before any event reaches the line, the WebSocket upgrade must preserve its connection through the response path. In a Worker, a 101 response carries a webSocket property as well as an HTTP status and headers. Rebuilding an HTTP response can lose the property that makes it an upgrade.

Our initial explanation for handling the socket before Astro was too broad. We tested which response copies actually retain the socket. In the local workerd experiment on 15 October, the whole-response copy worked, including when we changed a header afterwards. Reconstructing the response from named HTTP fields without the socket failed. Adding the socket explicitly made that reconstruction work.

Table 1. Three branches of the local response-copy experiment.Each successful branch exchanged hello, ping and pong and closed with code 1000.
Return pathObserved result
new Response(response.body, response)The upgrade completed
Named status and headers, without webSocketConstruction threw; no upgrade
Named fields including webSocket: response.webSocketThe upgrade completed

Restoring webSocket made the named-field reconstruction work. That control changed our explanation: the failure came from omitting the socket, rather than copying the response as such. We tested these constructions in local workerd; we did not exercise every response-handling branch in the full Astro application.

We retain the early Worker-entry branch because it makes the protocol path explicit. The Durable Object returns the upgrade, and the entry returns it without handing it to Astro’s page-rendering path. The generated Worker must also export the Durable Object class that the binding names. A functioning development handler is insufficient if a build wrapper drops that export. The WebSocket article includes the experiment, the generated-module contract and the separate production probes.

A new fragment can contain an older reading

Upcoming Skills Academy sessions use a different kind of information. A list answers what a visitor can sign up for; it does not need the event-by-event transport of the activity line. It still has to tell us when a successful response is reusing old information.

In the reader examined on 15 October 2026, we keep the raw source body in a memo per isolate and in the Cache API, each with a 60-second lifetime. We filter that body at reading time: remove meetings that have started, keep the earliest upcoming meeting for each offering, then sort the results.

Applying the time filter again can remove a meeting that has started. It cannot discover a meeting published after the stored body was fetched. A correct projection of an old list is still limited by the age of that list.

There is a second clock to follow. When a Cache API hit refills the memo, the code starts a new memo lifetime without carrying the cache entry’s age along. Rendering HTML adds another response with its own policy. A new memo entry or newly rendered fragment does not renew the source reading.

Diagram distinguishing source content fetched at t0, a memo refilled from a Cache API hit at t1, and fragment HTML rendered at t2. The cache objects get new start times while the source content can remain unchanged.
Figure 2. Illustrative ordering for the examined reader. These are separate cache clocks, not measured intervals or a calculated maximum data age. Source: TEEI, from the Skills Academy session reader examined for the series.

This is why cache age and data age need different observations. A header states how a response may be reused. A source timestamp identifies when a measurement was made, when the source provides one. Neither alone establishes the age of the information now visible in a tab.

Failure policy needs the same distinction. A valid empty list is a successful reading we can cache. An unreadable source is a different outcome and produces no-store in the examined island. Returning a useful explanation in a successful HTML fragment lets the reader distinguish those states. The full source and cache investigation includes the reader, its projection and the dated production captures.

Independent reads let each section refresh separately, but the first page can then combine values read at different moments. The selected snapshot design puts related first-render values in one shared payload with a generation timestamp. Its sections can still carry different observedAt times. Assembling them together does not make their source readings simultaneous or newer.

A shared initial snapshot also leaves the browser’s update rule to decide. If the activity line can advance after rendering, a later event must still be reconciled with the initial reading and restored browser state. Moving the first read does not settle which value may replace it.

Check the value that reaches the reader

Table 2. Browser observations from the original and proposed clients, using synthetic records.The durations identify records; they are not performance measurements.
CaseOriginal clientProposed local variant
Fragment pausedEmpty initial position; no socketSame
Empty ledger, then a new eventFallback appears; socket event replaces itSame
Filled ledger, then a new eventRetained record appears; newer event replaces itSame
Storage older than server record20-minute record → older 10-minute record → 20-minute record after helloKeeps the 20-minute record
Storage newer than server recordNewer stored record is replaced by older helloKeeps the newer stored record
Older event delivered after newest30-minute record → older 10-minute recordKeeps the 30-minute record
Duplicate or different event at equal timeRewrites the line and dispatches againKeeps the displayed record; no new write or dispatch
Fragment returns 503Empty position and no socket after 1.8 secondsSame

The failed-fragment case exposes a separate limitation that the ordering change does not fix. With neither fallback content in the initial slot nor a separately delivered updater, the browser has neither activity text nor a live connection when the fragment fails.

We also replayed initial HTML saved before changing the local ledger. With the same build and island key, that HTML requested a fresh fragment, opened a socket and showed a later event without a reload. This checks the handoff when the first document is reused; it does not test a deployment change or CDN expiry.

The original and proposed clients are separate variants in the local reproduction source package. It contains synthetic events, a local ledger and the browser cases. Use Node.js 22.12 or newer. After extracting the package, run npm ci, npm run build and npx playwright install chromium. Start npm run serve in one terminal; in another, run npm test. The README gives the loopback addresses for both variants and describes each assertion. The fixture records initial HTML, fragment HTML, received frames and the text left in the document. These are controlled local results, not a production release of the correction.

Our production checks answer a narrower question, and they observed two different production versions. On 15 October, the captured homepage was prerendered with no island request paths, and the ledger was empty; the socket exchanged hello and pong. A follow-up on 17 October captured a later version with nine distinct island paths, the activity line’s among them, and again completed that socket exchange, with no retained events returned. The island counts differ because the versions differ; we did not tie the 17 October capture to the code revision examined above. Those observations do not establish fragment insertion or a real completed session reaching a visitor’s screen.

The local experiment exercises the rendered fragment, client and synthetic event together. It does not exercise the programme platform’s outbox, production event ingestion, a global CDN cache or a workload at scale. Replaying saved HTML in the fixture is controlled reuse, not a measurement of Cloudflare’s cache. These distinctions let the result support a useful conclusion without turning it into a latency or capacity claim.

The useful test follows the value beyond the successful response. Identify what the first HTML contains. Observe which later response supplies the updater. Then send a controlled synthetic event through that updater and inspect the text it leaves behind, including when an earlier stage cannot answer or an older value arrives later.

Rendering puts a value on the page. The update rules decide which value the reader sees next.

In this series: Astro on Workers
ArticleWhat it covers
Astro Server Islands: Cache Age Is Not Data AgeWe keep Astro pages static while handling old measurements, failed source reads and island requests that never replace the fallback.
Why We Still Handle WebSockets Before AstroWe keep WebSocket upgrades outside Astro as a precaution. Locally, a copied response kept its socket; a rebuild without the socket broke the upgrade.
Worker First Is Not Middleware First: Astro on CloudflareIn our Astro setup, we got 404s for old image URLs. We trace why we needed redirects before the adapter, even with Worker first.
We Pruned 12,115 Astro Redirect Pages from Our BuildPruning Astro fallback pages reduced the files we shipped. Redirecting their old URLs needed a separate fix before the Worker.
Astro on Workers: the bugs between build and runtimeWe found a 500 before a cache hit, private values in the Worker bundle and missing sitemap routes. Each exposed a boundary we had to migrate.
Keeping Astro Content, Sitemaps and Search in SyncWe can read from one Astro collection and still disagree on what is published. For a built search index, we filter preview drafts out again.

Frequently asked questions

Can a cached Astro page receive live updates?

Yes. The initial HTML can be reused while a deferred component or browser client requests later information. The article follows one activity line and tests which response supplies its updater and which incoming value is allowed to replace the visible text.

Does server:defer keep a component updated while the page stays open?

It renders a separate fragment after the page arrives. Continuing updates require another mechanism. In the deferred implementation examined here, the fragment also delivers an inline client that receives WebSocket events.

Does a cache HIT prove that the page was prerendered?

No. It establishes reuse of a cached response, not whether a build or an earlier request produced that response. We identify the rendering mode from the pinned code and distinguish it from the production capture.

What did the local browser reproduction find?

It observed initial HTML, fragment insertion, socket startup and a visible update from a synthetic event. It also reproduced older stored state and late events replacing newer text. A separate proposed variant tests an explicit event-time acceptance rule; these results are local, not a production rollout.

What do the six related Astro articles cover?

They examine source freshness and fallbacks, WebSocket upgrades, Worker and asset routing, generated redirects, migration across build and runtime, and publication rules across pages, sitemaps and search.

More in Research & Engineering

Partnerships

Talk to us about building with TEEI

We use these details only to reply.