<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><title>TEEI Updates - TEEI Blog</title><description>Latest news, announcements, and updates from TEEI.</description><link>https://theeducationalequalityinstitute.org/</link><language>en</language><lastBuildDate>Wed, 28 Oct 2026 11:51:47 GMT</lastBuildDate><generator>Astro v5 RSS</generator><docs>https://www.rssboard.org/rss-specification</docs><item><title>She asked to learn Italian. We changed the rule.</title><link>https://theeducationalequalityinstitute.org/blog/one-message-fifteen-minutes-teei-language-afghan-women-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/one-message-fifteen-minutes-teei-language-afghan-women-en/</guid><description>An Afghan woman was waitlisted because of her nationality. She asked us to reconsider. TEEI Language is now open to Afghan women 18+, wherever they live.</description><pubDate>Tue, 27 Oct 2026 00:00:00 GMT</pubDate><content:encoded>A woman from Afghanistan wrote to us because she wanted to learn Italian.

She had tried to join [TEEI Language](/programmes/education/teei-language/), our free language practice programme, but had been placed on a waiting list. Afghanistan was not among the countries our programme was open to.

Her message explained what that decision meant to her. She had already lost educational opportunities because she was a woman. She was still determined to learn, develop her abilities and make choices about her future. Now, when she found an organisation offering free language practice, her nationality had become another obstacle.

She asked us to reconsider.

Her question was straightforward: why should someone already facing barriers to education meet another barrier when asking for a chance to learn?

She was right to challenge us.

Fifteen minutes after we read her message, she had a place, and we had decided to open TEEI Language to women from Afghanistan.

TEEI Language is now open to Afghan women aged 18 and over, wherever they live.

## If you are an Afghan woman reading this

&lt;div&gt;
&lt;div&gt;

TEEI Language is free. You need to be 18 or over, and you can live anywhere. You choose a volunteer conversation partner from their profiles and book an available time that works for you. Some volunteers say on their profile that they are women; if you prefer to practise with a woman, you can choose one of them. You decide whether to turn your camera on, and you can turn it off at any time during a session. The volunteers you book with see the name on your account and the country you come from, but not your email address. A profile photo is optional; if you add one, they can see it.

You can also join [TEEI Mentorship](https://mentorship.theeducationalequalityinstitute.org/signup?role=mentee), where professionals help with applications, interviews and decisions about what comes next.

**[Join TEEI Language](https://language.theeducationalequalityinstitute.org/signup?role=learner&amp;amp;origin=afghanistan-woman)**

&lt;/div&gt;
&lt;div&gt;
&lt;p&gt;&lt;strong&gt;به دری&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;TEEI Language رایگان است. باید ۱۸ ساله یا بالاتر باشید، و مهم نیست در کجای جهان زندگی می‌کنید. شما هم‌صحبت رضاکار خود را از میان پروفایل‌ها انتخاب می‌کنید و یکی از وقت‌های خالی او را که برایتان مناسب است رزرو می‌کنید. برخی از رضاکاران در پروفایل خود نوشته‌اند که زن هستند؛ اگر ترجیح می‌دهید با یک زن تمرین کنید، می‌توانید یکی از آنها را انتخاب کنید. این‌که کمره روشن باشد یا نه، تصمیم آن با خود شماست و در جریان جلسه هر وقت بخواهید می‌توانید آن را خاموش کنید. رضاکارانی که با آنها برای جلسه وقت می‌گیرید، نامی را که در حساب کاربری‌تان نوشته‌اید و کشوری را که از آنجا هستید می‌بینند، اما ایمیل شما را نمی‌بینند. عکس پروفایل اختیاری است؛ اگر عکسی بگذارید، آنها می‌توانند آن را ببینند.&lt;/p&gt;
&lt;p&gt;همچنین می‌توانید به &lt;a href=&quot;https://mentorship.theeducationalequalityinstitute.org/signup?role=mentee&quot;&gt;TEEI Mentorship&lt;/a&gt; بپیوندید؛ در آنجا متخصصان در درخواست‌نامه‌ها، مصاحبه‌ها و تصمیم‌گیری درباره گام‌های بعدی به شما کمک می‌کنند.&lt;/p&gt;
&lt;p&gt;صفحهٔ ثبت‌نام به دری نیست، اما می‌توانید از نسخهٔ انگلیسی آن استفاده کنید.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://language.theeducationalequalityinstitute.org/signup?role=learner&amp;amp;origin=afghanistan-woman&quot;&gt;ثبت‌نام در TEEI Language&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;p&gt;&lt;strong&gt;په پښتو&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;TEEI Language وړیا دی. باید لږ تر لږه ۱۸ کاله عمر ولرئ، او د نړۍ په هر ځای کې چې اوسېږئ، ګډون کولای شئ. تاسو د خبرو لپاره خپل رضاکار ملګری د رضاکارانو له پروفایلونو څخه غوره کوئ او یو داسې خالي وخت نیسئ چې تاسو ته مناسب وي. ځینو رضاکارانو په خپلو پروفایلونو کې لیکلي دي چې ښځې دي؛ که غواړئ له یوې ښځې سره تمرین وکړئ، کولای شئ له هغوی څخه یوه وټاکئ. دا چې کمره چالانه کړئ که نه، ستاسو خپله پرېکړه ده، او د ناستې پر مهال یې هر وخت بندولای شئ. هغه رضاکاران چې ورسره د ناستې وخت نیسئ، ستاسو نوم (هغه نوم چې په خپل حساب کې مو لیکلی) او ستاسو هېواد ویني، خو ستاسو برېښنالیک (ایمیل) نه ویني. د پروفایل عکس اختیاري دی؛ که عکس ولګوئ، هغوی یې لیدلای شي.&lt;/p&gt;
&lt;p&gt;تاسو کولای شئ په &lt;a href=&quot;https://mentorship.theeducationalequalityinstitute.org/signup?role=mentee&quot;&gt;TEEI Mentorship&lt;/a&gt; کې هم برخه واخلئ؛ هلته مسلکي کسان د غوښتنلیکونو او مرکو په برخه کې، او د راتلونکو ګامونو په اړه په پرېکړو کې درسره مرسته کوي.&lt;/p&gt;
&lt;p&gt;د نوم لیکنې پاڼه په پښتو نشته، خو تاسو یې په انګلیسي کارولای شئ.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://language.theeducationalequalityinstitute.org/signup?role=learner&amp;amp;origin=afghanistan-woman&quot;&gt;په TEEI Language کې نوم لیکنه&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;

## A barrier we could remove

[UNESCO estimates](https://www.unesco.org/en/articles/five-years-unesco-calls-full-restoration-right-education-afghan-girls-and-women) that 2.4 million girls in Afghanistan are excluded from secondary education. Afghanistan remains the only country in the world that formally bars girls and women from secondary school and university.

Behind those figures are people whose ambitions have not disappeared simply because their opportunities have been restricted.

The woman who contacted us was not asking us to solve the whole education crisis. She wanted an opportunity to learn Italian.

We could not reverse the restrictions she described. We could change the rule that kept her out of our programme.

That distinction mattered. There was something within our control, and an existing service she wanted to use.

Rather than making an exception for one person, we opened the programme to others in the same position.

## A rule, not a platform

The decision was quick because the programme was already running. We did not need to build a new platform or start a separate programme. We needed to change who could join.

TEEI Language already connects learners with volunteer conversation partners. Participants choose someone to practise with, book a session and meet on the platform. Matching, scheduling, video rooms and activity records are part of the same system.

The volunteers who give their time, the people running the programme, and the technology supporting them were already in place. That existing work made it possible to respond quickly.

By 27 October 2026, TEEI Language and TEEI Mentorship had recorded 9,644.6 hours of conversation. Our July snapshot recorded members in 148 countries. Both figures are documented in our [public activity record](/about/impact/).

## How you can help

**Volunteers.** The most direct help is a conversation. If you speak Italian, or another language a learner wants to practise, you can [become a volunteer conversation partner](/volunteer/become-a-volunteer/). You do not need a teaching qualification, only a language you speak confidently and time to practise it with someone learning. We welcome Dari and Pashto speakers who would like to help women take their first steps.

**Organisations already working with Afghan women.** If your organisation already supports Afghan women, you can [register with TEEI Network Partners](https://evenhall.theeducationalequalityinstitute.org/network/register) to connect the women you work with to TEEI’s programmes and contribute your own volunteers and expertise, without building a separate learning platform.

**Companies.** Through [Evenhall by TEEI](/partners/evenhall/), a company can bring its employees into TEEI’s programmes as conversation partners and mentors. Evenhall is free for companies, and the company’s documented contributions feed into the company’s own reporting. Companies can also contribute technology, learning resources or funding. [Apply for a free Evenhall workspace](https://evenhall.theeducationalequalityinstitute.org/start).

## A question for technology partners

[Practice Companion](/blog/inside-practice-companion-rooms-evidence-and-level-en/), our AI-supported learning companion, is being developed to complement human conversations and classes through writing, listening and speaking practice.

For some women, practising independently, without a camera or an appointment with someone they have not met, could be a more appropriate first step. They could also practise at times that suit them.

We want to explore this with Afghan women and technology partners: what women actually want, which privacy concerns need to be addressed, and what would make participation more accessible. That is an invitation to investigate together, not a claim that a camera-free tool is automatically safe.

## What her message opened

Online language practice cannot restore the schools and universities women have been denied. It can provide a real opportunity to continue learning.

She wanted to learn Italian.

Because she asked us to reconsider, other women from Afghanistan can now join her.

[Join TEEI Language](https://language.theeducationalequalityinstitute.org/signup?role=learner&amp;amp;origin=afghanistan-woman)</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>TEEI Language</category><category>Afghanistan</category><category>Women</category><category>TEEI Mentorship</category><category>Volunteering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>The Educational Equality Institute is now TEEI</title><link>https://theeducationalequalityinstitute.org/blog/the-educational-equality-institute-is-now-teei-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/the-educational-equality-institute-is-now-teei-en/</guid><description>From today we go by TEEI: language practice, mentoring, skills training and a partner network on one engine we built ourselves. Nothing else changes.</description><pubDate>Sat, 24 Oct 2026 00:00:00 GMT</pubDate><content:encoded>From today, 24 October 2026, we go by TEEI.

Our full name described where we started. TEEI describes everything we do now: language practice, mentoring, skills training and a partner network that lets organisations bring their people onto the platform, all on one engine we built ourselves.

## Where we are

- 12,740 completed conversations and 9,617 measured hours, read live from the platform on 24 October 2026.
- People from 148 countries are members of the platform, from our country snapshot of 23 July 2026.
- A verified hour cost $0.78 to deliver in September 2026. The figure is updated each month from actual costs.

## What stays the same

Nothing else changes. Our legal name remains The Educational Equality Institute, and our registrations, partnerships and programmes continue as before.</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><readingTime>1 min</readingTime><category>teei-updates</category><category>TEEI</category><category>Name change</category><category>Organisation</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>How Project Galileo Protects TEEI’s Platforms</title><link>https://theeducationalequalityinstitute.org/blog/how-project-galileo-protects-teeis-platforms-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/how-project-galileo-protects-teeis-platforms-en/</guid><description>How TEEI keeps free language and career platforms online for Ukrainians affected by the war and women from Latin America and the Caribbean</description><pubDate>Sun, 18 Oct 2026 00:00:00 GMT</pubDate><content:encoded>*The website, TEEI Language, TEEI Mentorship, Skills Academy, Evenhall, Network Workspace and the tools our staff use, and what stands in front of each of them.
Security and traffic figures read from our Cloudflare account between 14 and 18 October 2026.*

Oksana T. learned English in one-to-one sessions with volunteers on TEEI Language. In her film she talks about joining when she felt stuck, and about becoming less afraid to speak.

![Oksana T., a TEEI Language graduate, in a square film. She talks about joining the programme when she felt stuck, becoming less afraid to speak, and the volunteers from all over the world who gave their time.](https://r2.theeducationalequalityinstitute.org/videos/cockpit/square/oksana-t-1.mp4?v=2026-09-22b#square &quot;Oksana T., who learned English on TEEI Language, on what the sessions gave her. 1 min 21 s, with captions.&quot;)

Source: TEEI film library, with Oksana T.&apos;s agreement to its use on TEEI&apos;s pages; this cut was rendered on 22 September 2026.

Today, a learner can open TEEI Language on their phone, choose an available volunteer and start a conversation. Behind that experience are the web applications that organise participation, connect people and record their activity. Those applications run on Cloudflare, with Business-plan protection on our main zone provided through [Project Galileo](https://www.cloudflare.com/galileo/).

TEEI is a nonprofit, and our programmes are free. The participants in this story are **Ukrainians affected by the war, both those displaced abroad and those still living in Ukraine, and women from Latin America and the Caribbean, including women who have migrated or been displaced.** What people come for is ordinary, and it matters:

- finding a peer and a place to belong
- using a new language with more confidence in daily life
- talking through a CV or a job interview with someone who has done the job
- learning data, technology or project management skills

Volunteers from many countries give that time as conversation partners, mentors and teachers.

TEEI organises its programmes in four areas: connection, language, skills and work. Participants can combine language practice, skills learning and mentoring according to their needs, without following a fixed sequence. Three platforms on our main zone carry language, skills and work: TEEI Language, Skills Academy and TEEI Mentorship. Evenhall serves the companies whose employees take part. Network Workspace serves the organisations that bring their own people.

This article goes through each platform in this story in turn: what people do there, what stands in front of it, and what that protection handled up to 16 October 2026. The screens come from demo platforms and sample workspaces: every name in those screens belongs to a sample profile, and none of the faces or pictures in them shows a participant. The two films are different. Oksana T. and Ihor R. took part in TEEI&apos;s programmes and agreed to their films being used on TEEI&apos;s pages. The security and traffic figures were read from our Cloudflare account between 14 and 18 October 2026. Cloudflare&apos;s analytics are sampled, so those figures are estimates, and they count requests, not people, confirmed intrusions or prevented breaches. Each table, chart and note gives its own window, and their limits are listed at the end.

**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.

## How it fits together

A learner finds TEEI on the website and opens TEEI Language on a phone. The page comes from Cloudflare. They pick a volunteer who is free right now, and the volunteer&apos;s screen rings. When they join the session, the Worker authenticates the request and asks AWS for a meeting; from then on their voice goes through the Chime media service in Ireland. When they leave, the platform settles the hours that a volunteer&apos;s record and a partner company&apos;s report are later built from. When a volunteer gives their time through an employer, the company&apos;s managers see it in Evenhall. Some learners arrive through an organisation&apos;s link, and that organisation follows their participation in its own Network Workspace. People earn certificates and badges as they go.

Each of those screens is answered by a different Worker, or by the same Worker doing a different job, and every one of them sits on a hostname of our main zone. The voice and video of the session itself run on Amazon Chime SDK, outside the zone. Cloudflare&apos;s Managed Ruleset runs in front of every TEEI platform on the zone, so requests that match its signatures are blocked before they reach our code, whichever platform they arrive at.

![Four phone screens in a row, joined by arrows. First, the TEEI website&apos;s home page with the words Connection, Language, Skills, Work and a button that says I want to learn. Second, the home screen of TEEI Language for a sample learner, with a goal, sample volunteers and the next session. Third, a one-to-one practice session on a phone between demo personas, with the video in the middle and the controls along the bottom. Fourth, the home screen of TEEI Mentorship for a sample mentee, with the next mentoring session. Under each screen, the Worker that serves it and the Cloudflare feature it relies on.](/images/blog/teei-cloudflare-workers/journey-screens.svg#wide &quot;An example path through TEEI, across four screens and the Workers behind them. The names and portraits are sample profiles, and the larger pictures are scene images that the site and the apps use.&quot;)

Source: TEEI, captured from the website, the public tours of TEEI Language and TEEI Mentorship, and a live Amazon Chime SDK meeting with demo personas, 14 October 2026.

The platforms hold people&apos;s bookings, the records of the sessions they took part in, and the certificates they earn and share. Many of the people who use them are Ukrainians affected by the war, or people rebuilding a career or finding their way in a new job market.

Through Project Galileo, Cloudflare gives TEEI its **Business plan on our main zone, at no cost.** The main zone is more than the website. TEEI Language, TEEI Mentorship, Skills Academy, Evenhall and Network Workspace all live on hostnames inside it, and so do our internal tools. **The protection sits in front of the platforms where learners and volunteers meet, not only in front of the page that describes them.** Galileo&apos;s support is that plan on the zone. The Workers Paid plan, R2 storage, AI services and the other third-party services the platforms use are separate from it.

Most of the work is done by two layers. The first is the [Cloudflare Managed Ruleset](https://developers.cloudflare.com/waf/managed-rules/reference/cloudflare-managed-ruleset/), which Cloudflare maintains and keeps up to date. The version we run was last updated on 15 October. The second is a handful of narrow rules we wrote ourselves for problems we have actually had.

That first layer was not on from the start. Our zone has been on the Business plan through Project Galileo since 6 July 2026, but we turned on the Managed Ruleset only on 22 September, so the blocks counted in this article start that day. A plan that includes a protection is not the same as a protection that runs.

Table: What stands in front of each platform in this story. The Managed Ruleset runs in front of every TEEI platform on the main zone; the rest is set platform by platform.

| Platform | What people do there | In front of it |
|---|---|---|
| Website | read about the programmes and the people in them, check a figure, write to us | the Managed Ruleset, and our own rules for the impact page and the forms |
| TEEI Language | book a volunteer, answer a ring, open the room | the Managed Ruleset, and a rate limit on sign-in |
| TEEI Mentorship | find a mentor and book a session | the Managed Ruleset |
| Skills Academy | register for a workshop, a clinic, an expert Q&amp;amp;A or a class | the Managed Ruleset |
| Evenhall | invite employees, read their hours, prepare a report | the Managed Ruleset |
| Network Workspace | an organisation shares TEEI&apos;s programmes with its people and follows participation | the Managed Ruleset |
| Admin | where TEEI&apos;s staff run the programmes | the Managed Ruleset, and its own sign-in and role checks |
| Previews, staging and database tools | where we build, test and look after the data | Cloudflare Access, for selected ones |
| The video call | the conversation itself | not on the zone: it runs on Amazon Chime SDK |

## At a glance

Table: TEEI&apos;s main zone in figures.

| Measure | Figure | Note |
|---|---|---|
| Protection on the zone | | |
| Requests blocked by the Managed Ruleset | 99,082 | 22 September to 16 October; on from 10:28 UTC on 22 September |
| Of those, matching rules for files that can reveal how a site is built | 86 % | version-control, configuration and backup files, by the rule each request matched |
| Challenges from our rule on the impact page | 885 | from 11 October |
| Spam submissions blocked by our forms rule | 433 | all between 17 September, when the measurement starts, and 25 September |
| Requests stopped by Browser Integrity Check | 406 | 17 September to 16 October |
| DDoS-classified events | 0 | in the firewall data, 17 September to 16 October |
| Blocked by the Managed Ruleset at each platform&apos;s address, 22 September to 16 October | | |
| Website | 29,270 | |
| Evenhall | 1,899 | at its earlier address, which forwards to Evenhall |
| Skills Academy | 1,771 | its own hostname on the Mentorship Worker |
| TEEI Language | 1,770 | |
| TEEI Mentorship | 1,119 | |
| Traffic | | |
| Requests answered by the five core application Workers | 5.2 million | 17 September to 16 October |
| Runtime errors in those requests | 70 | the same window |
| Requests to the zone answered from cache | 7.7 million | of 10.7 million, 14 September to 13 October |
| Programmes | | |
| Sessions on TEEI Language and TEEI Mentorship | 12,517 | the public statistics on 14 October 2026 |
| Hours of conversation | 9,435.5 | the same reading |

## What a request meets on the way in

The same zone settings apply to every TEEI platform on the zone. They decide how a browser may connect, and what every answer tells it about connecting next time.

Table: The settings every request to the main zone meets. Read from the zone&apos;s configuration on 17 October 2026; the responses we captured on 18 October carry the HSTS and HTTP/3 settings.

| Setting | State | Note |
|---|---|---|
| Always Use HTTPS | on | a plain HTTP request is sent to HTTPS |
| SSL/TLS | strict | for connections from Cloudflare to an origin server |
| Minimum TLS version | 1.2 | |
| HSTS | on | including subdomains, for about six months |
| HTTP/3 | on | offered in every response we captured |

On 18 October 2026 we sent two requests from Oslo to show what happens next. The first asked for our impact page, the way a reader would. The second asked for `/.git/config`, a version-control file of the kind about 73 percent of the blocked requests asked for.



&lt;figure&gt;
&lt;p&gt;&lt;span&gt;&lt;span&gt;&lt;/span&gt;Captured from Oslo · 18 October 2026, 05:35 UTC&lt;/span&gt;&lt;span&gt;theeducationalequalityinstitute.org&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&lt;p&gt;Two requests, one zone&lt;/p&gt;&lt;p&gt;The same door, two answers&lt;/p&gt;&lt;/div&gt;
&lt;ol&gt;&lt;li&gt;Browser&lt;small&gt;Oslo&lt;/small&gt;&lt;/li&gt;&lt;li&gt;Cloudflare&lt;small&gt;data centre OSL, the rules; the probe stops here&lt;/small&gt;&lt;/li&gt;&lt;li&gt;Our Worker&lt;small&gt;the website&lt;/small&gt;&lt;/li&gt;&lt;/ol&gt;
&lt;div&gt;
&lt;section&gt;&lt;p&gt;A reader&lt;/p&gt;&lt;p&gt;GET /about/impact/&lt;/p&gt;&lt;ol&gt;&lt;li&gt;&lt;b&gt;Connection&lt;/b&gt;&lt;code&gt;HTTP/2 over TLS · strict-transport-security: max-age=15768000; includeSubDomains&lt;/code&gt;&lt;/li&gt;&lt;li&gt;&lt;b&gt;Managed Ruleset&lt;/b&gt;&lt;code&gt;passed&lt;/code&gt;&lt;/li&gt;&lt;li&gt;&lt;b&gt;Our Worker&lt;/b&gt;&lt;code&gt;200&lt;/code&gt;&lt;/li&gt;&lt;li&gt;&lt;b&gt;Ray&lt;/b&gt;&lt;code&gt;a4c52df86ee2568e-OSL&lt;/code&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/section&gt;
&lt;section&gt;&lt;p&gt;A probe&lt;/p&gt;&lt;p&gt;GET /.git/config&lt;/p&gt;&lt;ol&gt;&lt;li&gt;&lt;b&gt;Connection&lt;/b&gt;&lt;code&gt;HTTP/2 over TLS · the same header&lt;/code&gt;&lt;/li&gt;&lt;li&gt;&lt;b&gt;Managed Ruleset&lt;/b&gt;&lt;code&gt;Version Control - Information Disclosure · block&lt;/code&gt;&lt;/li&gt;&lt;li&gt;&lt;b&gt;Our Worker&lt;/b&gt;&lt;code&gt;never reached&lt;/code&gt;&lt;/li&gt;&lt;li&gt;&lt;b&gt;Answer&lt;/b&gt;&lt;code&gt;403 · Cloudflare&apos;s block page · ray a4c52e00fc8a5694-OSL&lt;/code&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/section&gt;
&lt;/div&gt;
&lt;p&gt;Both requests were sent by TEEI. The status codes, headers and ray IDs are copied from the responses, and the rule from our firewall events, where the second request appears under its ray ID. Passed and never reached follow from those answers. Nothing here is simulated.&lt;/p&gt;
&lt;/figure&gt;

The reader&apos;s request passed the ruleset and was answered by the website&apos;s Worker. The probe never got that far. Cloudflare&apos;s Managed Ruleset matched it to the rule Version Control - Information Disclosure, answered with its own block page and status 403, and recorded the event in our firewall data under the ray ID shown, `a4c52e00fc8a5694`. Both answers carry the same HSTS header and offer HTTP/3, and the `OSL` at the end of each ray ID names the data centre in Oslo that received them.

## The website and the stories

Between 22 September and 16 October, Cloudflare&apos;s Managed Ruleset blocked about 29,270 requests at the website&apos;s address. Across the zone, most of what it blocks are requests for version-control files such as `.git`. The website stays open to anyone, without a sign-in, while those requests are turned away before they reach our code.

The website is an [Astro 7](https://astro.build/) site that runs as a Worker with Static Assets. Most of our traffic goes there: of the about 5.2 million requests the five core application Workers answered in the 30 days to 16 October, the website&apos;s Worker answered about 3.1 million. People find the programmes there, reach us through its forms, and watch films from people who took part.

Oksana T.&apos;s film above is one of those on the [stories wall](/about/stories/). The films sit in an R2 bucket and reach the browser through the bucket&apos;s public domain, in the byte ranges the player requests when it plays. The player is set to `preload=&quot;none&quot;`, which asks the browser not to fetch the video ahead of playback; its poster image loads with the page. Ihor R.&apos;s film, further down, measured 6.7 MB on 15 October 2026, in the same square cut the stories wall plays.

What [Cloudflare&apos;s cache](https://developers.cloudflare.com/cache/) does for the zone is easiest to see by day, over the 30 days from 14 September to 13 October.

![Stacked area chart of requests per day to TEEI&apos;s main zone, 14 September to 13 October 2026, by cache status. In Cloudflare&apos;s sampled analytics about 7.7 million of 10.7 million requests were cache hits, and about 2.7 million carried the status none, bypass or updating.](/images/blog/teei-cloudflare-workers/cache-status-daily.svg#phone,fit &quot;Requests to TEEI&apos;s main zone by cache status. The zone carries the website and the platforms&apos; hostnames. A hit was answered from Cloudflare&apos;s cache, none means the response did not go through the cache, and dynamic is a kind of response Cloudflare does not cache by default.&quot;)

Source: TEEI, from Cloudflare&apos;s GraphQL Analytics API, HTTP requests to the zone by cache status and day, visitor requests only, read on 14 October 2026 at 18:26 UTC.

About 7.7 million of the 10.7 million requests in those 30 days, seven in ten, were answered from Cloudflare&apos;s cache. About 2.7 million carry the status none, bypass or updating, and the dynamic and miss layers together are under 300,000.

The same zone carries the redirects that keep old links alive. The website used to run on WordPress, and other sites still link to its old addresses, among them news sites and job boards in Ukraine. On 12 October we found 39 of those addresses that answered 404 and added them to a [Bulk Redirect](https://developers.cloudflare.com/rules/url-forwarding/bulk-redirects/) list, 78 entries with both slash forms. All 117 checks we ran answered 301 in one hop, kept the query string, and ended on a page that answered 200.

## Public figures that stay public

Anyone can open our [impact page](/about/impact/). When automated crawling of it increased, we did not put everyone behind a challenge. We wrote a narrow rule for that crawl instead. From 11 to 16 October it issued about 885 challenges.

[The public statistics](https://language.theeducationalequalityinstitute.org/api/lc/public-stats) that the Language Worker publishes said, on 14 October 2026 at 13:38 UTC, that 12,517 sessions and 9,435.5 hours of conversation have taken place across TEEI Language and TEEI Mentorship, with members in 148 countries.

The website shows the same counter. Its [impact page](/about/impact/) pins each published figure, so that a citation stays true, and under the record it prints the counter as it stands now. The website&apos;s Worker asks the Language Worker for that number, so the reader&apos;s address never reaches the counter, and reuses each reading for 30 seconds. The capture below was taken two hours after the reading above, so its live line is a little higher.

![The figure record on the impact page of TEEI&apos;s website, on a phone. It shows 11,984 completed sessions across TEEI Language and TEEI Mentorship, recorded since 22 April 2022, with a monthly chart and three checks: read from the live counter on 18 September 2026, defined in the measurement register, and pinned so that a citation stays true. Below the record, a line reads the live counter: 12,525 sessions, 541 more than the pinned figure, read on 14 October at 15:44 UTC.](/images/blog/teei-cloudflare-workers/impact-page-counter.svg &quot;The website&apos;s impact page on a phone: the pinned record, and under it the live counter from the Language Worker.&quot;)

Source: TEEI, captured from the impact page on TEEI&apos;s website at phone width, 14 October 2026.

## Forms that stay open

Partners, companies and volunteers reach us through public enquiry forms, and we want those forms open to anyone. In the 30 days we measured, a rule for known spam sources blocked about 433 submissions, all between 17 and 25 September. Since 26 September it has had nothing to block.

Those are two of the rules we wrote ourselves. They address the specific patterns we have observed on our services, and each one is kept as narrow as possible, so that public pages stay public. We are not publishing the exact rules.

## TEEI Language: the ring and the room

**The door.** Between 22 September and 16 October, the Managed Ruleset blocked about 1,770 requests at TEEI Language&apos;s address. Sign-in on TEEI Language also has a rate limit of its own, and in the 30 days we measured it recorded no events. In the 30 days to 16 October, the Language Worker answered about 1.4 million requests. For a learner, TEEI Language&apos;s pages are where a conversation is arranged and begun: the booking, the ring and the way into the room.

TEEI Language and TEEI Mentorship are two products on one platform, built from one React Router 8 codebase and deployed as two Workers. Language is where people practise a language through real conversations; Mentorship connects mentees with professional mentors.

A volunteer&apos;s home in TEEI Language shows the learner they meet next, the requests waiting for an answer and the hours they have given.

![Three crops of a volunteer&apos;s home in TEEI Language on a phone, from the demo platform. The first is the top of the page: the greeting, the learner the volunteer meets next, and that learner&apos;s question. Below it, a strip of the coming days and the confirmed session on Friday at 18:00. The second shows two requests waiting for an answer, nothing to write up, and three learners with nine verified hours together. It also shows the standing Senior Language Volunteer. The third lists the two requests, each a learner asking for a 30-minute conversation on their own topic, with the buttons Accept and Decline. Sample people.](/images/blog/how-corporate-volunteering-works/language-volunteer-home.svg &quot;TEEI Language on a phone: a volunteer&apos;s home. The learner they meet next brought a question, two more requests wait for an answer, and the hours given are counted. Sample people.&quot;)

Source: TEEI Language demo platform, 14 October 2026.

**The ring.** Some conversations do not wait for a booking. A volunteer can mark themselves available now, and a learner who sees them can start a session straight away. The volunteer&apos;s screen then rings, and the volunteer answers or declines. If nobody answers, the booking must be settled and the learner told, even if the volunteer&apos;s browser has been closed. That needs something that can hold a connection open and keep time after every browser has closed, which a request and a response cannot do. That is a [Durable Object](/blog/real-time-availability-with-durable-objects-en/).

![Sequence diagram with five columns: the learner&apos;s browser, the Language Worker, the Turso database, the LivePresence Durable Object and the volunteer&apos;s browser. The volunteer&apos;s browser holds a hibernating WebSocket to the Durable Object. The learner picks the volunteer; the Worker writes the booking to the database first, then notifies the Durable Object and waits at most about two seconds for it, whatever the answer. The Durable Object rings the volunteer&apos;s screen and sets an alarm for two and a half minutes. If the volunteer answers, the ring is settled and both join the room. If the alarm fires first, the Durable Object asks the database to settle the unanswered booking and tells the learner the authoritative outcome.](/images/blog/teei-cloudflare-workers/live-presence.svg &quot;A ring, from the learner&apos;s choice to the volunteer&apos;s screen. The database is written first, and the wait for the Durable Object is capped at about two seconds.&quot;)

Source: TEEI, drawn from the live presence service in TEEI Language, 14 October 2026.

**The room.** People open the room from their own TEEI page, check camera and microphone in a lobby, and talk. The page comes from the Language Worker, and the video call runs on Amazon Chime SDK. In the meeting and attendee fields, the meeting service receives opaque identifiers, the booking&apos;s generated ID and a pseudonymous user ID, rather than names or email addresses. The voice and video themselves take a separate path, through Chime&apos;s media service, and background blur lets someone take part without showing their home.

![The lobby before a one-to-one session in TEEI Language. On the left, the start time in Oslo and in Kyiv, and the learner&apos;s words from booking: I would like to practise talking about my work. On the right, the volunteer&apos;s camera preview, a button to join, and the camera, microphone and background blur settings.](/images/blog/teei-amazon-chime-sdk/room-lobby.webp#full,phone,1280x800 &quot;The lobby: the time in both cities, what the learner asked for when booking, and a camera and microphone check before joining.&quot;)

Source: TEEI, captured in a live Amazon Chime SDK meeting, with demo personas in place of real participants, 18 October 2026.

The [practice room article](/blog/from-practice-room-to-company-report-teei-amazon-chime-sdk-en/) shows the room in full.

![A one-to-one session in progress on a laptop. The learner fills the screen, the volunteer&apos;s own picture sits in the corner, the time left is shown at the top, and the controls for microphone, camera, screen sharing, raised hand, reactions, captions, chat and notes run along the bottom.](/images/blog/teei-amazon-chime-sdk/room-one-to-one.webp#full,phone,1280x800 &quot;A one-to-one session: the other person fills the screen, and everything else stays at the edges.&quot;)

Source: TEEI, captured in a live Amazon Chime SDK meeting, with demo personas in place of real participants, 18 October 2026.

**The distance.** TEEI&apos;s programmes are for people who are often on a phone, on a mobile network, in a country far from our database. By September, TEEI Language was quick in Western Europe and slow almost everywhere else. Over the week before 21 September, request-weighted regional averages of the data centres&apos; median Worker times on page requests were 247 milliseconds in Western Europe, 1,193 in the western United States, 1,656 in Japan and 2,723 in Oceania; the mix of pages was not controlled. A Worker runs in the data centre closest to the person by default, and our database has one location, in AWS Europe (Ireland), so the further away someone was, the more each trip to the database could cost. On 21 September we committed a placement setting so that our Workers [run next to the database](/blog/why-our-workers-run-near-the-database-not-the-user-en/) instead of next to the user; it reached production with the release of 22 September.

![Bar chart comparing TEEI Language pages in the seven days before the release on 22 September 2026 with the seven days to 14 October 2026, each week taken as a whole. The median page time went from 433 milliseconds to 177, the 90th percentile from 1,747 to 462, database round trips per page from 17.1 to 5.8, and the time per round trip from 123 to 55 milliseconds. Placement, batching, loader rewrites and indexes changed together.](/images/blog/teei-galileo/placement-weekly.svg &quot;TEEI Language pages across a combined release, one week as a whole on each side. Code removed trips.&quot;)

Source: TEEI, from the Worker logs that Logpush delivers to R2, for successful page requests that read the database, timed inside the Worker; read on 24 October 2026 from a random sample of the log files, 11.5 percent of them for the week before and 12.1 percent for the week after.

Placement, loader changes and indexes shipped together. The before-and-after figures describe that combined release and the traffic in each measurement window; they do not isolate the effect of placement. A request-local read coalescer batches eligible SQL reads into fewer transport calls; it does not deduplicate identical statements. Loader rewrites and indexes reduce other database work. Both weeks are summarised the same way: each week as a whole, from the same logs and the same field.

## TEEI Mentorship and Skills Academy

**The door.** Between 22 September and 16 October, the Managed Ruleset blocked about 1,119 requests at TEEI Mentorship&apos;s address and about 1,771 at Skills Academy&apos;s. The Mentorship Worker, which answers both, handled about 239,000 requests in the 30 days to 16 October.

**Finding a mentor.** On [TEEI Mentorship](https://mentorship.theeducationalequalityinstitute.org/), mentees browse approved mentors by expertise and request or book a session, and the mentor accepts when acceptance is required. A mentor helps with a CV, interview preparation, a job-search strategy, a career change or how hiring works in their industry.

![Two crops of a mentor&apos;s home in TEEI Mentorship on a phone, from the demo platform. The first is a request waiting for an answer. A mentee new to the mentor asks for a 45-minute session on Sunday 18 October at 19:00. They want to practise answering behavioural questions with someone who has hired before. The buttons Accept and Decline sit below. The second is the mentor&apos;s week, with two open times for mentees to book and one session booked on Saturday at 18:00. Below it, the group session the mentor hosts, From operations to product, with two people enrolled, and the mentor&apos;s recap of the last session. Sample people.](/images/blog/how-corporate-volunteering-works/mentorship-mentor-home.svg &quot;TEEI Mentorship on a phone: a mentor&apos;s home. A request from a mentee with the topic they want to practise, the week with open times, a group session, and the last recap. Sample people.&quot;)

Source: TEEI Mentorship demo platform, 14 October 2026.

Ihor R. is a career fellow on TEEI Mentorship. In his film he talks about moving to a different country, the help his mentors gave him with his LinkedIn profile, cover letter and CV, and wanting to work as a project manager.

![Ihor R., a career fellow on TEEI Mentorship, in a square film. He talks about moving to a different country, the help he got with his LinkedIn profile, cover letter and CV, and wanting to work as a project manager.](https://r2.theeducationalequalityinstitute.org/videos/cockpit/square/ihor-r-1.mp4?v=2026-09-22b#square &quot;Ihor R., a career fellow on TEEI Mentorship, on what his mentors helped him with. 1 min 22 s, with captions.&quot;)

Source: TEEI film library, with Ihor R.&apos;s agreement to its use on TEEI&apos;s pages; this cut was rendered on 22 September 2026. File sizes measured on 15 October 2026.

**Learning a skill.** [Skills Academy](https://skills.theeducationalequalityinstitute.org/) has two halves. Participants study at their own pace on DataCamp and take Google Career Certificates on Coursera. Alongside that, volunteers teach live: a workshop, a clinic, an expert Q&amp;amp;A, a webinar, or a class that meets weekly for two to six sessions. Mentors on TEEI Mentorship help participants choose learning resources, apply what they learned, review practical work and plan next steps. Skills Academy is served from a second hostname on the Mentorship Worker. TEEI brings the learners and does the scheduling. In a class, participants receive a certificate once the teaching team confirms the required attendance and reviewed work.

![The Discover page of Skills Academy on a laptop. Tabs for workshops, clinics, expert Q&amp;amp;As, webinars and classes sit above the search filters. The featured session is a sample expert Q&amp;amp;A with a button to register, and the week&apos;s sample sessions follow below it.](/images/blog/teei-cloudflare-workers/skills-academy-discover.webp#full,phone,1040x650 &quot;Skills Academy&apos;s Discover page, served from a second hostname on the Mentorship Worker. The sessions and hosts are samples.&quot;)

Source: TEEI, Skills Academy&apos;s Discover page with sample sessions, captured for TEEI&apos;s programmes page, October 2026.

An instructor&apos;s desk shows the next session with its registrations and the preparation for it, and the sessions after it.

![Two crops of an instructor&apos;s Skills Academy desk on a phone, from the demo platform. The first is the next session: a workshop on Tuesday 20 October, 19:00 to 20:30, called Tell your operations work as product decisions. It shows 3 of 12 places registered, 5 days until it starts, a written preparation in five steps, and the button Prepare. The second lists the two sessions after it. A clinic, Product CV review clinic, has 2 of 8 registered. An expert Q&amp;amp;A, Ask a product lead: your first ninety days, has 3 of 30 registered. Sample data.](/images/blog/how-corporate-volunteering-works/skills-academy-instructor.svg &quot;Skills Academy on a phone: an instructor&apos;s desk. The next workshop with its registrations and preparation, then the clinic and the expert Q&amp;amp;A after it. Sample data.&quot;)

Source: Skills Academy demo platform, 14 October 2026.

## Certificates people share

The [certificates and badges](/blog/from-completed-sessions-to-certificates-browser-rendering-r2-en/) people earn are kept in R2, in the bucket the website reads them from, and they are most of what R2 holds. The managed-artwork URLs we inspected use the website&apos;s own hostname, where its Worker reads the files from R2, so those requests meet the same Managed Ruleset as the website.

![Horizontal bar chart of R2 storage by bucket group on 14 October 2026: 18.04 GB in 31 buckets, of which certificates and badges hold 10.92 GB and films 5.58 GB. The platform logs hold most of the objects, 318,916 of 381,023.](/images/blog/teei-cloudflare-workers/r2-buckets.svg#phone,fit &quot;What fills R2. Certificates and badges hold most of the bytes; the platform logs hold most of the objects.&quot;)

Source: TEEI, from Cloudflare&apos;s GraphQL Analytics API, R2 storage per bucket, the latest sample in the 48 hours before 14 October 2026 at 13:36 UTC. Stored bytes, metadata included.

By object count the order flips: the platform logs hold more than eleven objects for every certificate or badge.

![A sample TEEI Language certificate of achievement with the placeholder name Your name, for the Language Learner role, with a seal and placeholder certificate details.](/images/blog/teei-cloudflare-workers/certificate-sample.webp#document,1123x794 &quot;A sample TEEI Language certificate. The name and the certificate details are placeholders.&quot;)

Source: TEEI, the sample certificate shown on the website&apos;s pages for TEEI Language, October 2026.

The share cards for published sessions have a bucket of their own. A separate renderer outside Cloudflare writes them with an [R2 API token](https://developers.cloudflare.com/r2/api/tokens/), which is scoped to buckets, not to prefixes within them. R2&apos;s [temporary credentials](https://developers.cloudflare.com/r2/api/s3/temporary-credentials/) can be narrowed to a prefix, but the renderer does not use them. We use separate buckets to keep the share-card renderer&apos;s write access separate from certificate storage, so a fault in the renderer cannot overwrite the certificates people put on LinkedIn.

The public [/verify/](/verify/) page lists TEEI&apos;s registration numbers, official domain and current programme names. Individual credentials have a separate verification page, which links to their badge and certificate files.

## Evenhall: what companies see

[Evenhall](/partners/evenhall/) is where a partner company&apos;s managers invite employees into TEEI&apos;s programmes, see what they did there, and turn it into a report they can file. Between 22 September and 16 October, the Managed Ruleset blocked about 1,899 requests at the address Evenhall had under its earlier name, which now forwards to Evenhall. The Evenhall Worker answered about 332,000 requests in the 30 days to 16 October.

A company applies, and once TEEI has reviewed the application, its managers invite employees into TEEI Language, Skills Academy or TEEI Mentorship.

Evenhall sends email, takes in applications from companies and syncs employee lists from HR systems. None of that should keep the person who pressed the button waiting, so Evenhall uses three [Cloudflare Queues](/blog/reliable-email-cloudflare-queues-transactional-outbox-en/): applications, email and HR system sync. A manager pastes the employees&apos; email addresses and presses Send invitations. For each address the Worker saves an invitation and an outbox row in separate database writes, then publishes a queue message that references the row. Queue messages identify work held in the database rather than carrying the recipient list or message content. Consumers use guarded claims and provider idempotency where supported to handle retries. Recovery and terminal handling differ between message families; the detailed queue article describes their boundaries.

Each of the three queues has a [dead-letter queue](https://developers.cloudflare.com/queues/configuration/dead-letter-queues/). This is the email queue:

![Flow diagram of Evenhall&apos;s email queue. A manager presses a button in Evenhall. The Evenhall Worker writes an outbox row to the database and publishes a message that references the row to a Cloudflare Queue. Before sending, the queue consumer reads the row, acquires a conditional claim and calls the email provider with an idempotency key. On exponential retry paths the delay doubles from one minute up to an hour; a message retried after every failed delivery reaches its dead-letter queue after eight retries. That consumer passes it to the email-family handler before acknowledging it. Applications and HR system sync have their own consumers. Separately, a sweep every five minutes alerts the team when a scheduled job goes stale.](/images/blog/teei-cloudflare-workers/evenhall-queues.svg &quot;Evenhall&apos;s email queue carries a reference to the outbox row, not the recipient or the message. Delivery state stays in the database; a successful queue acknowledgement does not prove delivery to a recipient.&quot;)

Source: TEEI, drawn from Evenhall&apos;s queue configuration and consumers, 14 October 2026.

![The Invite employees page in Evenhall&apos;s demo workspace for a sample company. A field takes email addresses, one per line or separated by commas, with a Send invitations button below it. A panel beside it explains how inviting works: add people, send invitations so that each person receives a single-use link, and track who has joined.](/images/blog/teei-cloudflare-workers/evenhall-demo-invite-2026-10-23.webp#full,phone,1280x800 &quot;Invite employees: for each address, an invitation and an outbox row in the database, and a reference to the row in the queue.&quot;)

Source: TEEI, captured from Evenhall&apos;s public demo workspace with sample data, 23 October 2026.

What Evenhall adds is the company&apos;s view: who took part, how much time they gave, and reports a manager can prepare for leadership.

![Two Evenhall phone screens from the demo workspace. The first lists the most recent completed sessions, each with a colleague, a programme, the hours and the status Verified. The second is one colleague&apos;s record, with their hours and sessions.](/images/blog/how-corporate-volunteering-works/evenhall-session-record-2026-10-23.svg &quot;Evenhall on a phone: the most recent completed sessions, each with a person, a programme, the hours and a status, and one colleague&apos;s own record of hours and sessions. Demo workspace; the people and figures are fictional.&quot;)

Source: Evenhall demo workspace, October 2026.

The hours in it are counted from the room, not the calendar. When both people join a session, we record from TEEI&apos;s video room when each of them connected and left. For a one-to-one session, the hours are the time both were connected to the same room at the same time, not the length of the booking. A session whose evidence has not arrived is kept apart and is not counted as hours. &quot;Verified&quot; here means verified by TEEI, from our own room records. It does not mean audited by anyone else.

A company&apos;s CSR manager sees each named employee&apos;s hours and session records. Employees never see each other&apos;s records, and participants&apos; names stay out of the company&apos;s reporting. Personal learning conversations and mentoring notes stay private. The company never sees the conversation, only the record that it took place.

## TEEI Network Partners: who else the protection reaches

People do not always find TEEI on their own. Through TEEI Network Partners, NGOs, universities, churches and other community organisations bring the people they work with into TEEI&apos;s programmes, free of charge. They can introduce learners to language practice, mentoring and skills classes, contribute their own volunteers and teachers, or do both.

Each organisation has its own workspace to manage its programmes, follow participation and see the contributions made through its community. The people they bring to TEEI use the same platforms as everyone else, rather than separate services built for each organisation.

That also means they get the same protection. Whether someone joins TEEI Language, TEEI Mentorship or Skills Academy directly or through a Network Partner, their requests pass through our main zone and meet the same Managed Ruleset, on the Business plan Project Galileo gives us. So does each organisation&apos;s own workspace. An organisation does not need to run or protect a platform of its own to give its people this. The protection reaches beyond TEEI&apos;s own outreach to the communities our partners bring with them.

## Staff tools and how we ship

Admin is where TEEI&apos;s staff run the programmes. It has its own sign-in and role checks, and the Managed Ruleset runs in front of it as well. Selected preview and staging environments, our database tools and specific internal endpoints sit behind [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/policies/access/).

We deploy often. TEEI Language had 235 production deploys between 27 June and 14 October 2026, and TEEI Mentorship 107. Every production deploy is written to a deploy log and [probed](/blog/deploying-the-exact-worker-you-tested-en/). If the probe fails, the release script restores the previous version on its own, as long as that version still works with the database schema.

That has happened twice. On 22 July a Language deploy failed its probe at 07:10:12 UTC, because the probe expected a heartbeat the new version had only just added; the previous version was back six seconds later, and a release with the corrected probe went out at 08:05 and passed. On 28 July a Mentorship deploy rolled back because the probe still expected the closed service Mentorship had been before it opened on 24 July. The recorded rollbacks on 22 and 28 July were triggered by probe problems. They demonstrate the rollback path, not confirmed application failures; the probes were corrected.

We keep logs long enough to answer a question asked next quarter, not only one asked this week, and Workers Logs keep a few days. So Language and Mentorship send their logs to R2 through [Logpush](https://developers.cloudflare.com/workers/observability/logs/logpush/). Logpush needs two switches, and having only the first is the trap: with observability on and Logpush off, the Logpush job exists, reports no error and delivers nothing. The two settings now sit next to each other in the configuration, with a comment that says so.

Silence is the other thing we watch for. Evenhall&apos;s daily sync went stale on 12 July 2026, and nobody was told for two months. Now a sweep runs every five minutes and sends an alert when a scheduled job goes stale. It has been on in production since 28 September.

Table: The five core application Workers in 30 days, 17 September to 16 October 2026, from Cloudflare&apos;s analytics. The Workers article counts every production Worker to 14 October, so its totals differ.

| Worker | Requests {bar} | Runtime errors |
|---|---|---|
| Website | 3,127,040 | 0 |
| TEEI Language | 1,412,860 | 50 |
| Evenhall and Network Workspace | 332,040 | 20 |
| TEEI Mentorship and Skills Academy | 239,070 | 0 |
| Admin | 114,850 | 0 |
| Total | 5,225,860 | 70 |

In the 30 days to 16 October, the five core application Workers answered about 5.2 million requests, and about 70 of them ended in a runtime error. Keeping the platforms on one account means a small team sees all of them in one place: one set of analytics, one set of logs and one view of every Worker.

## What the protection handled

Since 22 September 2026, Cloudflare&apos;s Managed Ruleset has run in front of every TEEI platform on the zone. From 22 September to 16 October 2026, it blocked **an estimated 99,000 requests** across TEEI&apos;s main zone.

![Line chart of requests blocked per day by Cloudflare&apos;s Managed Ruleset on TEEI&apos;s main zone, 17 September to 16 October 2026. The line stays at zero until 22 September, when the ruleset was turned on at 10:28 UTC, then runs at around 4,000 a day: 99,082 in 25 days, with the most in a day, 8,106, on 24 September. A dashed line marks the day it was turned on. Weekends are toned.](/images/blog/teei-galileo/managed-blocks-daily.svg#phone,fit &quot;Requests blocked by the Managed Ruleset per day. Nothing was blocked by it before 22 September, when it was turned on at 10:28 UTC; that first day counts from then.&quot;)

Source: TEEI, from Cloudflare&apos;s GraphQL Analytics API, firewall events from the Managed Ruleset by day, 17 September to 16 October 2026 (UTC), read on 17 October 2026 at 09:41 UTC.

The rules those requests matched show what they asked for:

![Horizontal bar chart of what the requests blocked by Cloudflare&apos;s Managed Ruleset asked for, by the rule they matched, 22 September to 16 October 2026: about 73 percent version-control files such as .git, 13 percent configuration and backup files that could leak information, 4 percent fake search-engine bots, 3 percent a known code-injection signature and 7 percent everything else.](/images/blog/teei-galileo/managed-rule-categories.svg#phone,fit &quot;What the blocked requests asked for, as approximate shares of the estimated 99,000 requests the Managed Ruleset blocked, by the Cloudflare rule each request matched.&quot;)

Source: TEEI, from Cloudflare&apos;s GraphQL Analytics API, firewall events from the Managed Ruleset by rule, 22 September to 16 October 2026 (UTC), read on 17 October 2026 at 09:41 UTC. Configuration and backup files join two rule families, common files and file extensions.

Together, **about 86 percent of the blocked requests matched rules for files that can reveal how a site is built or where its secrets are kept.**

A request that matches a signature does not tell us who sent it or why. In June 2026, for Project Galileo&apos;s twelfth anniversary, Cloudflare published its [first comprehensive report on cyberattacks targeting civil society](https://cf-assets.www.cloudflare.com/dzlvafdwdttg/5YmIHaAURvy8ZKjJ1CcQo6/1335a737054c44915ace715348e62697/BDES-9187_Cyberattacks_against_civil_society_Project_Galileo_Anniversary_Report.pdf), announced in [its anniversary post](https://blog.cloudflare.com/celebrating-12-years-of-project-galileo/). It found that civil society organisations faced attempts to exploit website vulnerabilities at a rate **more than seven times higher** than other Cloudflare customers.

Cloudflare&apos;s Browser Integrity Check stopped about 406 more requests in the 30 days, most of them between 20 and 22 September. No DDoS-classified events appeared in the firewall data for the period.

The counts for each platform, given above, are also in the [public data file](/data/galileo-figures-2026-10-17.json). They are counted at each platform&apos;s own hostname and do not add up to the total, because internal tools and other host variants are not listed.

## If you run a small nonprofit on Cloudflare

This is what we would tell another organisation in our position:

1. **Check which protections are actually enabled.** We had the Business plan from July and turned on the Managed Ruleset in September. Check which rulesets are active, not just which features the plan includes.
2. **Lock down the basics.** Turn on Always Use HTTPS, strict TLS and a minimum of TLS 1.2. Turn on HSTS once every hostname it will cover, subdomains included, works over HTTPS.
3. **Put internal tools and previews behind Cloudflare Access.** We use Cloudflare Access to control access to selected preview and staging environments, database tools, and specific internal endpoints. Our admin application uses its own authentication and role checks.
4. **Write narrow rules for the problems you actually have.** One specific pattern is better than a challenge for every visitor.
5. **Keep your logs longer than the dashboard does.** We send the Workers logs for TEEI Language and TEEI Mentorship to R2 with Logpush, so that we can still answer next quarter&apos;s questions about those services.

If you would rather not run a platform at all, your organisation can bring its people onto TEEI&apos;s [as a Network Partner](/for/network-partners/).

## The engineering series behind this story

This article is the story of the people the platforms serve and the protection around them. The engineering behind it is a series of notes. It starts with [How TEEI Runs Its Platforms on Cloudflare Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/). Two reading paths branch from it: [Astro on Workers](/blog/our-homepage-is-a-live-system-astro-cloudflare-workers-en/), for our website, and [React Router 8](/blog/one-codebase-two-programmes-react-router-8-cloudflare-workers-en/), for TEEI Language, TEEI Mentorship, Evenhall and admin.

## Thank you, Cloudflare

For the learner, the purpose is a conversation, a class or a useful exchange with a mentor. For the volunteer, it is a practical way to contribute. The technology has to support those encounters, while the organisation protects the information and services around them.

Through Project Galileo, Cloudflare gives TEEI Business-plan protection on our main zone. That protection sits in front of the website, the platforms through which people from Ukraine, Latin America and the Caribbean meet volunteers from many countries, and the workspaces of the organisations that bring them. Thank you to the Project Galileo team.

Cloudflare is welcome to share this story, quote it, and use TEEI&apos;s name and logo.

## About the figures

- Security and Workers traffic figures are estimates from Cloudflare&apos;s sampled analytics, for 17 September to 16 October 2026 (UTC), read on 17 October 2026. The Managed Ruleset figures cover 22 September to 16 October. Programme totals are taken from TEEI&apos;s public programme statistics, read on 14 October 2026.
- The counts per platform are for the Managed Ruleset at each platform&apos;s own hostname, 22 September to 16 October 2026. Evenhall&apos;s count is for its earlier hostname, which now forwards to the current one; both are served by the same Worker.
- The 5.2 million requests and the about 70 runtime errors cover the five core application Workers in the 30 days to 16 October 2026. The [Workers article](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/) counts every production Worker in the 30 days to 14 October, so its totals differ. A runtime error is counted apart from HTTP error responses, and neither is an uptime figure.
- The two requests in the panel were sent by TEEI from Oslo on 18 October 2026 at 05:35 UTC, and their headers and the firewall event are kept with the article&apos;s working files.
- Requests measure load, not people.
- A request that matches a signature does not mean we run the software it targets, or that the attempt would have worked.
- No DDoS-classified events appeared in the firewall data for this period. That does not show that no attack was attempted, and the observed activity does not establish a targeted campaign against TEEI.
- The Managed Ruleset, custom rule and DDoS figures, the Workers totals and the programme figures, with their measurement periods and definitions, are in a [public data file](/data/galileo-figures-2026-10-17.json). The cache and storage figures come from the [Workers article](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/).
- [How TEEI Runs Its Platforms on Cloudflare Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/) covers the architecture, the limits we hit and how we deploy.</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Project Galileo</category><category>Cloudflare</category><category>Security</category><category>Cloudflare Workers</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>How Evenhall Drafts Company Reports with the Vercel AI SDK</title><link>https://theeducationalequalityinstitute.org/blog/how-evenhall-drafts-company-reports-vercel-ai-sdk-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/how-evenhall-drafts-company-reports-vercel-ai-sdk-en/</guid><description>Streaming, tool-grounding and human approval: one sample company report, from volunteer hours to a shared link, drafted with the Vercel AI SDK.</description><pubDate>Sat, 17 Oct 2026 00:00:00 GMT</pubDate><content:encoded>*Streaming, tool calls that look up how each cited figure is counted, and a manager who approves. One sample company&apos;s
quarterly report, followed from the volunteer hours to a shared link, and measured in a local copy
of Evenhall on 17 October 2026.*

TEEI&apos;s free programmes connect people from Ukraine, and women from Latin America and the Caribbean,
with volunteers who help them practise a language and build a career. The Educational Equality
Institute runs those programmes on its own platform: TEEI Language, then Skills Academy, then TEEI
Mentorship, with Evenhall by TEEI as the workspace for partner companies.
**[Evenhall](https://evenhall.theeducationalequalityinstitute.org/)** is where a partner company&apos;s
managers invite employees into TEEI&apos;s programmes, see what they did there, and turn it into a report
they can file. Companies pay nothing for Evenhall.

This article follows one manager through one report: a quarterly impact report for Horizon
Technologies, a company we invented for the purpose. The hours its colleagues gave become a block of
data. A model drafts the report from that data and looks up how the figures it cites are counted. The manager
reads the draft, approves it and shares it. The model&apos;s part runs through the
[Vercel AI SDK](https://ai-sdk.dev/), an open-source TypeScript toolkit from Vercel, released under
the Apache 2.0 licence with its [source on GitHub](https://github.com/vercel/ai). At each stage we
show what the SDK does, what our own code does around it, and what the manager decides. The SDK runs
inside Evenhall&apos;s Worker on Cloudflare; Vercel hosts nothing of ours.

This is one integration, in Evenhall. It is not the basis of every AI feature at TEEI: the AI checks
in our programmes, described in [What TEEI Runs on AWS](/blog/everything-teei-runs-on-aws-en/), call
Amazon Bedrock from AWS Lambda through the AWS SDK. We wrote this for developers who build
multi-step tool calls with the AI SDK, for the people who build the SDK, and for the managers who
file the report. For SDK developers, the core is stage 3: why we read `result.stream` through a
filter of our own instead of forwarding `textStream`. The code is the version Evenhall ran in production on 17 October 2026, on AI SDK
7.0.92.

**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.

The screens of Horizon Technologies and the timings and counts of the sample run come from runs on
17 October 2026, in a copy of that version of Evenhall on one machine. The copy ran on local
databases with one synthetic company, Horizon Technologies (sample), and every screenshot of it
carries our label Synthetic sample data; its 15 volunteers, their sessions and its figures are
invented. The model calls were real: Claude Sonnet 4.6 on Amazon Bedrock, through an EU
cross-Region inference profile, the model Evenhall uses for this report type. We changed one thing
in the copy, a tap that writes down every part of the SDK&apos;s stream with its time, which Evenhall
does not record. Times are seconds after the manager clicked Generate, on one clock. They describe
this run on this machine, not production latency. We made two runs. The second is the one we show;
the first is in the measurements near the end.

## Key facts

- A report draft in Evenhall is one call to `streamText` from AI SDK 7.0.92, inside a Cloudflare
  Worker, with two tools that look up how a figure is counted and the record behind it.
- The SDK runs the loop: it calls Claude on Amazon Bedrock, checks each tool input against a zod
  schema, runs the tool and calls the model again. In the sample run that took four steps and 19
  tool calls, and 29,523 input and 6,380 output tokens by the SDK&apos;s own count.
- Everything around that call is Evenhall code: the block of data the model writes from, the
  filter that decides which text is report and which is the model&apos;s notes, the stream to the browser, the checks
  and records after the run, the approval and the sharing.
- In the sample run the first block of the report reached the browser after 11.2 seconds and the
  last after 66.9. Recording the draft and its 63 cited figures took another 12.3 seconds.
- The prompt tells the model to write no notes about its process. It wrote one in every step, in
  both runs: seven notes in seven steps. The filter let none of them through.
- A manager approves every report. Approval locks it, a draft with an unknown citation cannot be
  approved, and only an approved report can be shared through a public link.
- You can reproduce the filter with the SDK&apos;s mock model, without a key or a network.

## One report in five stages

The report passes through five stages, and the AI SDK works in one of them. The stages before it
decide what the model sees. The stages after it decide what becomes the company&apos;s report.

![Five cards in a row, joined by arrows. Stage 1, hours recorded: colleagues volunteer in TEEI Language, Skills Academy and TEEI Mentorship, and completed sessions become records with their hours; TEEI&apos;s programmes and Evenhall. Stage 2, the data block: Evenhall reads one company&apos;s hours, sessions, programmes and monthly trends for the period, and its published stories; Evenhall. Stage 3, the AI draft, highlighted: a model writes the draft in steps, looking up how each figure is counted, and the report streams to the screen block by block; Vercel AI SDK, Claude on Amazon Bedrock, Evenhall&apos;s filter. Stage 4, review and approval: the manager reads the draft on screen and approves it, and approval locks the text; the manager. Stage 5, report and sharing: a PDF, an auditor package, or a public link the manager can stop sharing; the manager, in Evenhall. A legend marks the AI SDK, Evenhall code, and the browser or an outside service.](/images/blog/evenhall-ai-sdk-reports/journey.svg#wide,phone,fit &quot;One quarterly report in five stages. The AI SDK does its work in stage 3; the other four stages are Evenhall and the manager.&quot;)

Source: TEEI, drawn from Evenhall&apos;s code and the sample run, 17 October 2026.

## Stage 1: the hours

A report is only as good as the records under it, and those records start in the programmes. A
colleague at a partner company volunteers in TEEI Language, Skills Academy or TEEI Mentorship. Sessions
become records with a person, a programme, the hours and a status. Live sessions in all
three programmes run in our own video rooms, and a verified hour is measured from the room: how a
meeting becomes hours is in
[From Practice Room to Company Report](/blog/from-practice-room-to-company-report-teei-amazon-chime-sdk-en/),
and what we count as a verified hour, and what we do not, is in
[How TEEI Counts a Verified Volunteer Hour](/blog/how-teei-counts-a-verified-volunteer-hour-en/).
Hours a volunteer reports themselves are kept apart from verified hours all the way into the report.

A manager sees the programmes from Evenhall, where they invite colleagues and share a link to join.

![Evenhall&apos;s programmes page in the sample workspace for Horizon Technologies (sample), with our label Synthetic sample data. The heading reads TEEI programmes, with a button to send invitations by email and tabs for an overview, TEEI Language, TEEI Mentorship, Skills Academy and invitations. Two cards show TEEI Language, where adults practise a language in conversation with a colleague who speaks it, and TEEI Mentorship, where Ukrainian professionals rebuilding a career get an hour of perspective from a colleague. Each card says it is live for the sample company, with a programme page button and a link to copy.](/images/blog/evenhall-ai-sdk-reports/programmes.webp#full,phone,1062x833 &quot;The programmes page in the sample workspace: what colleagues can join, with a link the manager can share.&quot;)

Source: TEEI, captured from a local copy of Evenhall with synthetic sample data, 17 October 2026. The company, its colleagues and its figures are invented.

From there, completed sessions land in the company&apos;s record. This is how that looks on a
phone in Evenhall&apos;s public demo:

![Two Evenhall phone screens from the demo workspace. The first lists the most recent completed sessions, each with a colleague, a programme, the hours and the status Verified. The second is one colleague&apos;s record, with their hours and sessions.](/images/blog/how-corporate-volunteering-works/evenhall-session-record-2026-10-23.svg &quot;Evenhall on a phone: the most recent completed sessions, each with a person, a programme, the hours and a status, and one colleague&apos;s own record of hours and sessions.&quot;)

Source: Evenhall demo workspace, October 2026. The people and figures are fictional.

For the sample run, we gave Horizon Technologies one quarter of activity: 15 volunteers, 607
completed sessions and 822.25 hours between 1 July and 30 September 2026, in all three programmes.
Of those hours, 339.75 were verified and 482.5 were self-reported. The company also has published
stories from its volunteers, the kind a report may quote.

## Stage 2: the data block

The manager opens Reports, switches to AI narrative and opens the report settings. A report type is a template and a prompt, from a weekly summary to an annual impact report, with
types for the European Sustainability Reporting Standards and a custom report beside them. Our
manager picks the quarterly impact report and sets the period to the third quarter.

![The Configure report dialog in the sample workspace, scrolled to the end of the list of report types. Each type has a short description and the label Sonnet model: CSRD and ESRS compliance, ESRS E1 climate change, ESRS S1 own workforce, ESRS S2 value-chain workers, ESRS G1 business conduct, and a custom report. Below it, the date range, with presets for the last 7 days, 30 days, 3 months and year, and Custom selected, from 07/01/2026 to 09/30/2026. A Done button closes the dialog.](/images/blog/evenhall-ai-sdk-reports/configure.webp#screen,641x749 &quot;Configuring the draft: the end of the list of report types, and the period, here 1 July to 30 September 2026.&quot;)

Source: TEEI, captured from a local copy of Evenhall with synthetic sample data, 17 October 2026.

When the manager clicks Generate, Evenhall reads the company&apos;s figures for the period into a block
of data. This is the part of the prompt the model writes from. Here it is from the sample run,
shortened:

*Shortened for publication: the figures, programmes, trends and volunteers are cut short, and the stories are left out.*

```xml
&lt;data&gt;
{&quot;start&quot;:&quot;2026-07-01&quot;,&quot;end&quot;:&quot;2026-09-30&quot;}
{&quot;workspaceCreatedOn&quot;:&quot;2026-10-17&quot;,&quot;firstActivityOn&quot;:&quot;2025-08-20&quot;,&quot;lastActivityOn&quot;:&quot;2026-10-17&quot;}
{&quot;totalHours&quot;:822.25,&quot;totalSessions&quot;:607,&quot;uniqueVolunteers&quot;:15,&quot;activeProgrammes&quot;:3, …,
  &quot;verifiedHours&quot;:339.75,&quot;selfReportedHours&quot;:482.5, …}
[{&quot;name&quot;:&quot;TEEI Mentorship&quot;,&quot;hours&quot;:179.75,&quot;sessions&quot;:153,&quot;volunteers&quot;:15}, …]
[{&quot;date&quot;:&quot;2026-07&quot;,&quot;hours&quot;:274.5,&quot;sessions&quot;:204}, …]
[ … five published stories … ]
[{&quot;name&quot;:&quot;Volunteer 1&quot;,&quot;hours&quot;:59.75,&quot;sessions&quot;:40}, …]
&lt;/data&gt;
```

Three things about this block matter for the rest of the article. First, it is where the model gets
the company&apos;s activity. Second, it is careful with people. In the list of the most active volunteers, a
colleague appears by name only if they have chosen to appear in rankings; the others appear under a
pseudonym, as all ten do in the sample, and no contact details are sent. Third, the verified and
the self-reported hours arrive as separate fields, so the draft can keep them apart.

After the data comes a template. For the quarterly report it has eight sections, each opened by a
marker and a heading:

*Shortened for publication: five of the eight sections are left out.*

```markdown

## Executive Summary

## Quarter at a Glance
…

## Appendix: Key Figures
```

Those markers come back later. They are how our filter knows where the report starts.

The system prompt sets the rules for figures. Every number must be grounded by a tool call before
it appears in the prose, and cited inline as `[ref:slug]`. A map names the methodology behind each
field of the data block, word for word from the run:

*Verbatim excerpt, wrapped to fit; four more lines of the map are left out.*

```text
FIGURE_METHODOLOGY:
Each &lt;data&gt; field below is measured by the methodology after the arrow. Before stating a figure
from one of these fields, call lookup_methodology with that slug and cite it. A figure from a
&lt;data&gt; field that is not listed here has no registered methodology: mark it [ungrounded].
- stats.totalHours, programmes[].hours, trends[].hours, top_volunteers[].hours -&amp;gt; [ref:platform-recorded-volunteer-hours-v1]
- stats.verifiedHours -&amp;gt; [ref:platform-verified-volunteer-hours-v1]
- stats.selfReportedHours -&amp;gt; [ref:platform-self-reported-volunteer-hours-v1]
```

And one line of the prompt will matter in stage 3:

*Verbatim, wrapped to fit.*

```text
- Output only the finished report, starting with the first section marker or heading. No notes
  about your process or tools (&quot;I&apos;ll&quot;, &quot;Let me&quot;, &quot;Now I have&quot;, &quot;Here is the report&quot;).
```

Before any model call, Evenhall checks whether it has written this report already. It hashes the
version of the prompt template, the report type, the data and any instruction the manager added. If
a stored draft has the same key, the browser gets that draft, and the model is not called. We tried
it: asked again for the same report type over the same data as our first run, Evenhall answered in
0.33 seconds with three frames, `cached`, one `text` frame holding the whole draft, and `done`. The
cache is Evenhall&apos;s, not the SDK&apos;s.

Nothing in stage 2 is the AI SDK. The data, the template, the prompt and the cache decide what the
model sees and whether it is asked at all.

## Stage 3: the AI draft

### Where the SDK sits

The request goes to Evenhall&apos;s report route, a React Router 8 application on Cloudflare Workers. The
route builds the prompt and two tools for this company and calls `streamText`. From there the SDK
talks to Claude on Amazon Bedrock through its Bedrock provider, runs the tools inside the Worker
when the model asks for them, and hands the model the results. What comes back passes through our
filter, and the route sends the report to the browser as server-sent events. When the run is
complete, the route checks and stores the draft.

![Diagram of where the AI SDK sits in Evenhall, in three bands. At the top, the manager in the browser picks a report type and a period, watches the draft arrive, reads it, approves it and shares it; an arrow down asks for a draft, an arrow up brings blocks as server-sent events. In the middle, Evenhall code on Cloudflare Workers: the report route reads the company&apos;s figures into a data block, checks for a stored draft of the same data, builds two tools for this company and calls streamText. Below it, highlighted, Vercel AI SDK 7: streamText runs a loop of model steps, up to eight; the Bedrock provider makes each call; a tool runs when the model asks for one, then the next step begins. It sends each step to Claude on Amazon Bedrock, Sonnet 4.6 through an EU cross-Region inference profile, and receives the parts. Around the SDK, inside the Worker: our filter reads result.stream, drops notes and sends closed blocks as server-sent events; the two lookups, defined with the AI SDK&apos;s tool() and a zod schema, return how a figure is counted and the record behind it; and after the stream, citations are checked, the draft is stored, the prompt, raw text and tool log are kept, and each cited figure is recorded.](/images/blog/evenhall-ai-sdk-reports/map.svg#wide,phone,fit &quot;Where the AI SDK sits. The SDK runs the loop and the calls to the model; the route, the filter, the lookups and everything after the stream are Evenhall code.&quot;)

Source: TEEI, drawn from Evenhall&apos;s code, 17 October 2026.

Put as a list of jobs, stage 3 divides like this:

| Job | Done by |
|---|---|
| Call Claude on Amazon Bedrock and read its streamed answer | AI SDK, Bedrock provider (`@ai-sdk/amazon-bedrock` 5.0.74) |
| Check each tool input against its zod schema, run the tool, give the model the result | AI SDK (`tool()`, `streamText`) |
| Decide whether to call the model again after a tool call | AI SDK, with our stop condition |
| Report every part of every step as a typed stream, then the steps and the token usage | AI SDK (`result.stream`, `result.steps`, `result.totalUsage`) |
| Read the company&apos;s figures, write the prompt and the template | Evenhall |
| Look up a methodology or a ledger row | Evenhall, inside each tool&apos;s `execute` |
| Tell report text from the model&apos;s notes | Evenhall&apos;s filter |
| Send blocks and heartbeats to the browser, serve the cache | Evenhall&apos;s route |
| Check citations, store the draft, record each cited figure | Evenhall&apos;s route, after the stream |
| Approve, export and share | The manager, in Evenhall |

### Two tools

The tools are how a figure gets its source. `lookup_methodology` returns how a figure is calculated:
the formula, the version, a short fingerprint and the reporting standard it maps to, if any.
`lookup_evidence` returns the ledger row behind one figure: its value, its confidence and its
methodology. Both are defined with the SDK&apos;s `tool()` and a zod schema, so the SDK checks the
model&apos;s input against the schema before our code runs:

*Shortened for publication: the lookup itself is left out.*

```ts
export function buildMethodologyTools(runtimeEnv: RuntimeEnv, companyId: string) {
  return {
    lookup_methodology: tool({
      description:
        &quot;Look up an active methodology slug to use in a citation. ... Call this BEFORE emitting a numeric claim that is grounded in a registered methodology.&quot;,
      inputSchema: z.object({
        slug: z.string().min(1).max(128),
      }),
      execute: async (input) =&amp;gt; {
        // Read the methodology registry. A retired or unknown
        // slug returns an error object the model can read, not an exception.
      },
    }),
    // lookup_evidence: the same shape, keyed by entity type, id and field.
  };
}
```

The tools are built for every request, closed over the company. The model can ask for a slug, but
the company is fixed when the tools are built and is not part of their input, and the model never
sees the Worker&apos;s environment. An error comes back as an object the model can read, so a wrong slug
is an answer, not a failed run.

This is what the first tool call of the sample run returned, word for word:

*Verbatim, from the run&apos;s tool log.*

```json
{
  &quot;slug&quot;: &quot;platform-recorded-volunteer-hours-v1&quot;,
  &quot;version&quot;: &quot;1.0.0&quot;,
  &quot;fingerprint&quot;: &quot;b5ec&quot;,
  &quot;title&quot;: &quot;Recorded volunteer hours (all completed sessions)&quot;,
  &quot;formula&quot;: &quot;hours = sum(hours) over completed sessions dated in the window&quot;,
  &quot;esrsRef&quot;: null,
  &quot;methodologyType&quot;: &quot;internal&quot;,
  &quot;groundsFields&quot;: [&quot;stats.totalHours&quot;, &quot;programmes[].hours&quot;, &quot;trends[].hours&quot;, &quot;top_volunteers[].hours&quot;]
}
```

`lookup_evidence` is harder for the model to use, because it has to name a ledger row by entity
type, id and field. In the sample workspace no ledger row existed under the identifiers it chose,
such as the entity `stats.totalHours` with the field `value_current`, so each of its twelve evidence
lookups returned `{&quot;error&quot;:&quot;evidence_not_found&quot;}`. The model said what it would do about that in
its last note, quoted below: cite every figure by its methodology only. The draft it went on to write carried 63 citations, each with a slug and the fingerprint a
methodology lookup had returned.

### A loop, not a call

A step is one call to the model. When the model asks for a tool, the SDK runs it and calls the
model again with the result, in a new step, and one step can ask for several tools. `streamText`
keeps going for as long as its stop condition allows, and the default condition is
[`isStepCount(1)`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text): one step.

We found what that means on 12 July 2026, in a live test during our move to Bedrock. The model
called its tools in the first step, the run ended, and every tool-grounded draft stopped at its
preamble. The fix is one line:

*Shortened for publication: retries and other options are left out.*

```ts
const result = streamText({
  model,           // Claude on Amazon Bedrock
  system,
  prompt,
  tools,           // built for this company, for this request
  maxOutputTokens,
  // The default stops after one step: the model calls its grounding tools
  // and the run ends before any narrative is written. Eight steps cover the
  // tool batches plus the writing turn for the largest report types.
  stopWhen: stepCountIs(8),
});
```

In AI SDK 7, `stepCountIs` is exported as an alias of
[`isStepCount`](https://ai-sdk.dev/docs/reference/ai-sdk-core/is-step-count). The step limit was
set while Evenhall was on version 6; the filter below was written on version 7, where one rename
matters: the [full part stream is now `result.stream`](https://ai-sdk.dev/docs/migration-guides/migration-guide-7-0),
with `fullStream` kept as a deprecated alias. We read `result.stream`.

The sample run used four of its eight steps. In the first, the model called `lookup_methodology`
seven times, once for each methodology it meant to cite. In the second and third it called
`lookup_evidence` six times each. The fourth step called no tool and wrote the report. Each step
sends the conversation so far, tool results included, so the input grows step by step: 5,896
tokens in the first step, then 7,145, 7,878 and 8,604. Over the whole run the SDK counted 29,523
input and 6,380 output tokens. Evenhall itself does not read those counts; our tap read them
from `result.totalUsage`.

### The run on one clock

![Timeline of the sample run in two panels. The first panel covers the first 12 seconds. The model steps row shows step 1 from 1.6 to 3.4 seconds, step 2 from 4.2 to 6.4, step 3 from 7.3 to 9.6 and step 4 from 10.3 seconds on. The tool calls row shows 7 lookups in step 1, 6 in step 2 and 6 in step 3. The notes row shows a short note at the start of each step, all dropped. The frames row shows the metadata frame at 0.46 seconds, a heartbeat at 10.45 and the first text at 11.24. The second panel covers the whole run to 80 seconds: steps 1 to 3 are short, step 4 writes the report until 66.6 seconds, 64 text frames arrive between 11.2 and 66.9 seconds, and after the stream Evenhall spends 12.3 seconds before the done frame at 79.2 seconds.](/images/blog/evenhall-ai-sdk-reports/timeline.svg#wide,phone,fit &quot;The sample run on one clock: four model steps, nineteen tool calls, a note at the start of every step, and the frames the browser received.&quot;)

Source: TEEI, measured in a local copy of Evenhall with synthetic sample data, 17 October 2026: the frames as the browser received them, and the SDK&apos;s parts from our tap. Not production latency.

Read left to right, the run went like this. The route called `streamText` 0.39 seconds after the
click, and the browser had the draft&apos;s ids, in the metadata frame, 0.46 seconds after the click. The first step began to stream at 1.60 seconds, when
Bedrock had sent its response headers; across the four calls those took between 0.73 and 1.20
seconds. Each of the first three steps opened with a sentence of notes, then the tool calls, then the step ended and
the next began about 0.8 seconds later. The fourth step began at 10.29 seconds with a note of its
own, and the first block of the report reached the browser at 11.24 seconds.

On the screen, the manager saw a Stop button, a note that the draft was grounded in 0 sources so
far, and an empty report card. Then the first heading arrived:

![The report card at 13 seconds into the sample run. The title Quarterly Impact Report has a Stop button and a New report button. A grey band reads Grounded in 0 sources. Below it, the heading Executive Summary, with nothing under it yet. On the right, the grounding sources panel reads 0 sources: no sources yet, and when a report is generated, every methodology it cites is listed there.](/images/blog/evenhall-ai-sdk-reports/streaming-13s.webp#screen,phone,1026x459 &quot;At 13 seconds: the first heading is on screen. Its paragraph is still being written, and it is held until it is complete.&quot;)

Source: TEEI, captured from a local copy of Evenhall with synthetic sample data, 17 October 2026.

From then on the report arrived block by block, a paragraph or a table at a time, until 66.88
seconds: 64 text frames, 20,118 characters. As each section came in, its figures turned into
numbered chips that name their methodology, and the sources panel filled up:

![The report at 30 seconds into the sample run, partway through the TEEI Mentorship section. It reads that TEEI Mentorship delivered 179.75 hours, citation 28, Recorded volunteer hours (all completed sessions), across 153 sessions, citation 29, Sessions completed, again with all 15 volunteers, citation 30, Volunteers (distinct people), active in the programme, with sessions of about 1.2 hours each. On the right, the sources panel lists methodologies 4 and 5, verified and self-reported volunteer hours, with the citations that use each.](/images/blog/evenhall-ai-sdk-reports/streaming-30s.webp#screen,phone,1010x275 &quot;At 30 seconds: the TEEI Mentorship section, each figure followed by a numbered chip naming its methodology, and the sources on the right.&quot;)

Source: TEEI, captured from a local copy of Evenhall with synthetic sample data, 17 October 2026.

## The notes on the page

On 28 September 2026, in a walkthrough of Evenhall, a draft came out wrong. Evenhall had an earlier
name then. The draft on screen opened with the model talking to itself: &quot;I&apos;ll ground every numeric
claim before writing. Let me look up...&quot; and only then the report.

The SDK had done what it says. `result.textStream` is the text of the run, and a multi-step run can
have text in more than one step. Before it calls a tool, the model may write a sentence or two, and
those sentences are notes to itself, written before it had its numbers. We had forwarded
`textStream` to the browser as it came, and in a run with tool calls that forwards the notes too.
We fixed it the same evening, and the fix went live on 30 September.

The prompt line quoted in stage 2 was already there: output only the finished report, no notes
about your process or tools. The sample run shows what that instruction is worth on its own. The
model wrote a note at the start of all four steps, word for word:

- Step 1: &quot;I&apos;ll gather all the methodology and evidence grounding before writing the report. Let me
  make all independent lookups simultaneously.&quot;
- Step 2: &quot;Now let me look up all the evidence rows in parallel.&quot;
- Step 3: &quot;Now let me look up programme-level evidence rows.&quot;
- Step 4: &quot;No evidence rows exist in the lineage registry for this dataset … all figures will be
  cited by methodology only. I have all the methodology fingerprints needed and can now write the
  full report.&quot; Then a horizontal rule, and then the first section marker. (Where the three dots
  stand, the model wrote a dash.)

The first run did the same in each of its three steps. Seven steps, seven notes, and the prompt
forbids every one of them. In these two runs the instruction did not stop a single note, so the
work falls to the filter, within the limit described below.

![A table of the sample run in four columns: the time, what result.stream carried, what our filter did, and what went to the browser. At 0.46 seconds the browser receives the metadata frame with the revision and report ids. At 1.60 seconds step 1 opens with the note I&apos;ll gather all the methodology and evidence grounding before writing the report, which the filter holds; at 2.18 seconds come 7 calls to lookup_methodology, and the note is dropped. Step 2 at 4.20 seconds opens with Now let me look up all the evidence rows in parallel, held, then 6 calls to lookup_evidence, each answered evidence_not_found, and the note is dropped. Step 3 at 7.26 seconds does the same with Now let me look up programme-level evidence rows. Step 4 at 10.29 seconds opens with No evidence rows exist in the lineage registry for this dataset, all figures will be cited by methodology only, and a rule, which the filter drops at the anchor. At 10.45 seconds the browser gets a heartbeat. At 11.24 seconds the section marker for the executive summary is sent as text, at 11.43 the heading Executive Summary, at 16.48 the first paragraph, in a frame of 1,537 characters, and 61 more text frames follow through the appendix until 66.9 seconds, with 6 heartbeats. At 79.21 seconds the browser receives done, with the report id, title and time.](/images/blog/evenhall-ai-sdk-reports/streams.svg#wide,phone,fit &quot;Same run, two streams: what the SDK&apos;s part stream carried, what our filter did with each part, and what the browser received.&quot;)

Source: TEEI, measured in a local copy of Evenhall with synthetic sample data, 17 October 2026. The notes are quoted word for word from the run, with a dash in the fourth shown as three dots.

## Streaming the report, not the model&apos;s working notes

`result.stream` carries the same text with the structure around it: a `start-step` when a step
begins, `text-delta` parts as text arrives, a `tool-call` part when the model asks for a tool, a
`finish-step` when the step ends, and an `error` part if the run fails
([the part types](https://ai-sdk.dev/docs/ai-sdk-core/generating-text)). Our filter reads those five
and ignores the rest, including `tool-result`. It follows four rules:

- Text in a step that calls a tool is dropped, before and after the call, unless it has already
  gone out as a closed block (the limit is below). It was written before the model had its
  numbers, so it is working notes by definition.
- Until the report has started, text is held back until the first anchor: a Markdown heading, or a
  section marker from the template, at the start of a line. Anything in front of the anchor is
  dropped, which also removes a &quot;Here is the report:&quot; lead-in. A marker quoted inside a note does
  not count.
- In the step where the anchor appears, the report is sent in closed blocks. When a blank line or a
  heading closes a paragraph, list or table, everything up to it goes out; the block still being
  written is held.
- Any other step that calls no tool is held and sent whole when it finishes. That covers the later
  steps of a report, and a report written without an anchor.

In the sample run, the first three rules did all the work. Steps 1 to 3 called tools, so their
notes were dropped. Step 4&apos;s note and its rule came before the first section marker, so they were
dropped at the anchor. From the marker on, step 4 went out in 64 text frames.

The heart of the filter is two branches of one `switch` over the parts:

*Verbatim excerpt.*

```ts
case &quot;text-delta&quot;: {
  const text = part.text ?? &quot;&quot;;
  raw += text;
  // Text after a tool call in the same step is notes as well.
  if (stepHadToolCall) break;
  if (streaming) {
    pending += text;
    yield* releaseClosedBlocks();
    break;
  }
  held += text;
  if (!narrativeStarted) {
    const start = findReportStart(held);
    if (start &amp;gt;= 0) {
      narrativeStarted = true;
      streaming = true;
      pending = held.slice(start);
      held = &quot;&quot;;
      yield* releaseClosedBlocks();
    }
  }
  break;
}

case &quot;tool-call&quot;:
  stepHadToolCall = true;
  held = &quot;&quot;;
  pending = &quot;&quot;;
  break;
```

Every `text-delta` is added to `raw` before any rule applies, and when the stream ends the filter
hands `raw` to the route, which stores it in the run&apos;s record after the draft. The notes are not
thrown away. In the sample run they were kept next to the log of the tool calls, with their input
and output, where someone reviewing a draft can read them. The four notes quoted above came from
that record.

For this article we also ran the filter on two runs of the SDK&apos;s mock model and drew them part by
part. The second shows the limit of the rules:

![Two runs on a timeline, each part marked with what the filter does at that moment: send, hold or drop.](/images/blog/evenhall-ai-sdk-reports/steps.svg#wide,phone,fit &quot;Two mock runs through the filter; each row shows what the filter does at that part. In the first run, the notes in step 1 are held and then dropped when the step calls a tool, and step 2 goes out block by block. In the second run, a paragraph already sent stays sent when the same step later calls a tool.&quot;)

Source: TEEI, traced with the AI SDK mock model on 17 October 2026.

## Why blocks and not tokens

A chunk that has reached the browser cannot be taken back. Streaming token by token would show the
first words of every step at once, and some of those steps turn out to end in a tool call. No
filter can stream every token and also drop notes it only recognises as notes later. So we chose
to stream by block. In the step where the report starts, a paragraph goes out when a blank line or
a heading closes it. A later step goes out when it ends without a tool call. Text still being
written when a tool call comes is dropped.

The price is a longer wait before the first text, and pauses while a block is being written. The
sample run puts a number on it. The first text arrived 0.95 seconds after step 4 began, the time
the model spent on its note and rule. The longest pause between two text frames was 5.05 seconds,
from 11.43 to 16.48 seconds, while the model wrote the first paragraph, which arrived as one frame of 1,537 characters.
Token by token, those characters would have appeared over the same five seconds; block by block,
the manager saw a heading and a placeholder, and then the paragraph at once. The browser keeps a
placeholder for the report and a Stop button on screen for the whole run, so the wait is visible.

The rule has a limit, and our own trace shows it. If the model writes a heading and a paragraph,
and then, in the same step, a note and a tool call, the heading and paragraph have already gone out
and stay; the unfinished note is dropped. A note the model finishes as its own paragraph before the
tool call goes out the same way, and we traced that too. The filter decides at the end of each
block because that is the last point it can decide. In the sample run, no step that wrote report
text called a tool afterwards, so the limit did not come into play.

The pattern is wrong for some interfaces. In a chat, where a reader expects to see the model work,
we would use `textStream`. The filter fits when the text is a document that will be stored,
exported and approved.

## How we test it

The filter takes any async iterable of parts, so its tests feed it synthetic streams and never call
a model. Seven tests cover the rules. Ten more feed it hostile streams: a heading whose hashes
arrive one character at a time, a section marker split before its dashes, a marker quoted inside a
note, a stream that stops inside a tool step, an error halfway through. When the stream carries an
`error` part, the filter throws, and the failed run is not saved as a draft.

To check that the tests can fail, we broke the filter four ways, one at a time: sending the same
text but only at the end of each step, holding everything, sending unfinished blocks, and letting a
tool call keep the open block. Each break turned a test red. All 17 tests passed on 17 October 2026.

## The wire to the browser

The SDK hands us parts; the browser gets server-sent events that Evenhall writes by hand. The route
wraps the filter&apos;s output in a `ReadableStream` with a `TextEncoder`, and every frame is one
`data:` line of JSON followed by a blank line:

*Shortened for publication: the time limit, the guards around each write, the metadata payload, error handling and storage are left out. The comment on the reader and the last comment are ours.*

```ts
const HEARTBEAT_INTERVAL_MS = 10_000; // 10 seconds
const sseStream = new ReadableStream({
  async start(controller) {
    const encoder = new TextEncoder();
    const reader = textStream.getReader(); // the blocks our filter releases

    // Emit metadata frame at stream start so the React hook can capture
    // revisionId / reportRootId before the first token arrives.
    controller.enqueue(encoder.encode(`data: ${metaFrame}\n\n`));

    // Heartbeat: send every 10s to keep connection alive and reset client timeout
    const heartbeatInterval = setInterval(() =&amp;gt; {
      const hb = JSON.stringify({ type: &quot;heartbeat&quot; });
      controller.enqueue(encoder.encode(`data: ${hb}\n\n`));
    }, HEARTBEAT_INTERVAL_MS);

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      fullText += value;
      const sseData = JSON.stringify({ type: &quot;text&quot;, content: value });
      controller.enqueue(encoder.encode(`data: ${sseData}\n\n`));
    }
    // Then: check the citations, store the draft and its record, send &quot;done&quot;.
  },
});
```

There are six kinds of frame:

| Frame | When the route sends it | What it carries | In the sample run |
|---|---|---|---|
| `metadata` | Once, before any text | The ids of the draft&apos;s revision | At 0.46 s |
| `heartbeat` | Every ten seconds for as long as the run lasts | Nothing | 7, from 10.45 s |
| `text` | Each time the filter releases a block | The block | 64, from 11.24 s to 66.88 s |
| `done` | After the draft is checked and stored | The report&apos;s id, title and time | At 79.21 s |
| `cached` | Instead of a run, when a stored draft matches | The stored draft&apos;s id, then one `text` frame with all of it | Only in the cache test, at 0.33 s |
| `error` | When the run fails | A message for the manager | None |

On the wire, the start and the end of the sample run looked like this:

*From the browser&apos;s capture of the run; the revision ids are shortened.*

```text
data: {&quot;type&quot;:&quot;metadata&quot;,&quot;payload&quot;:{&quot;revisionId&quot;:&quot;…&quot;,&quot;reportRootId&quot;:&quot;…&quot;,&quot;parentRevisionId&quot;:null, …}}

data: {&quot;type&quot;:&quot;heartbeat&quot;}

data: {&quot;type&quot;:&quot;text&quot;,&quot;content&quot;:&quot;\n&quot;}

data: {&quot;type&quot;:&quot;text&quot;,&quot;content&quot;:&quot;## Executive Summary\n\n&quot;}

…

data: {&quot;type&quot;:&quot;done&quot;,&quot;reportId&quot;:&quot;2405f54a-2750-4893-8747-25b2c1f602b0&quot;,&quot;title&quot;:&quot;Horizon Technologies (sample) - Quarterly Impact Report (Q3 2026)&quot;,&quot;generatedAt&quot;:&quot;2026-10-17T17:04:23.663Z&quot;}
```

The browser reads the response body itself rather than through `EventSource`, because the request
is a POST with the manager&apos;s settings. It splits the body into lines, keeps the ones that start
with `data: `, parses the JSON and switches on the type:

*Shortened for publication: the metadata, cached and error branches are left out.*

```ts
switch (event.type) {
  case &quot;text&quot;:
    // Accumulate in buffer, flush on timer (reduces re-renders)
    textBufferRef.current += event.content || &quot;&quot;;
    scheduleFlush();
    break;

  case &quot;done&quot;:
    flushNow(); // Ensure all buffered text is committed
    setState((prev) =&amp;gt; ({
      ...prev,
      isStreaming: false,
      reportId: event.reportId || prev.reportId,
      reportTitle: event.title || prev.reportTitle,
      generatedAt: event.generatedAt || prev.generatedAt,
    }));
    break;

  case &quot;heartbeat&quot;:
    // Server heartbeat - just resets the inactivity timer (already done above)
    break;
}
```

Text is gathered and drawn every 50 milliseconds, so a burst of frames costs one render. Every
chunk of data resets a timer, and if nothing arrives for long enough, the browser stops waiting
and offers to try again. That is why the heartbeat exists: in the sample run, ten seconds passed between the metadata frame and the first
heartbeat with no text at all, because the model was in its tool steps. If the connection fails
with a network error, the browser tries again by itself, at most twice, after one second and then
three. Each try is a new request.

When a run fails partway, the SDK either puts an `error` part in the stream or, for an error that
stops the stream, such as a network failure, throws from it. Our filter throws in both cases, the
route sends an `error` frame, and nothing is stored as a draft. The browser shows what it has
received with the error and a button to try again.

## After the stream

The last block reached the browser at 66.88 seconds and `done` at 79.21. The 12.3 seconds between
them are Evenhall&apos;s, not the model&apos;s. In the sample run the route did this, in this order:

1. **The citations are checked.** Every `[ref:slug]` in the draft is compared with the active
   methodologies in the registry. A citation to a slug that is not there is rewritten to
   `[ungrounded]` and counted as a violation. All 63 citations in the sample draft named an active
   methodology, so there were none.
2. **The draft is stored.**
3. **The record of the run is stored**: the system prompt and the prompt, the raw text of every
   step with the notes, and the log of the tool calls with their input and
   output.
4. **Each cited figure is recorded.** Evenhall extracts the figures the draft cites and writes a row
   for each in the audit trail, marked as extracted by the run and as estimated, not measured. The
   sample draft gave 63 rows, and writing them took most of the 12.3 seconds.
5. **The run&apos;s cost is noted, and the revision is closed.**
6. **`done` goes to the browser**, with the report&apos;s id, title and time.

The check in step 1 says that each cited methodology exists and is in use. It does not say that
every sentence is right; that is what the manager&apos;s reading is for. The SDK takes no part in any
of the six steps. The route reads the raw text from our filter and the tool log from
`result.steps`, after the stream has drained.

When `done` arrives, the screen changes from a stream to a draft:

![The finished draft in the sample workspace. The title reads Horizon Technologies (sample) - Quarterly Impact Report (Q3 2026), with buttons to regenerate, open in the viewer, and start a new report. A grey band reads: Generated 2026-10-17 17:04 UTC, grounded in 7 sources, followed by the seven methodology references, platform-distinct-volunteers-v1, platform-recorded-volunteer-hours-v1, platform-completed-sessions-v1, platform-verified-volunteer-hours-v1, platform-self-reported-volunteer-hours-v1, programme-reported-people-reached-v1 and replacement-value-volunteer-time-v1. Below it the Executive Summary begins: Horizon Technologies concluded Q3 2026 (1 July to 30 September 2026) with one of its most consistent volunteering quarters on record. On the right, the grounding sources panel lists 7 sources, each with the numbers of the citations that use it, marked Verified.](/images/blog/evenhall-ai-sdk-reports/draft-done.webp#screen,phone,1062x491 &quot;Done at 79 seconds: the band names the time and the seven methodologies the draft cites, and the panel lists each with the citations that use it.&quot;)

Source: TEEI, captured from a local copy of Evenhall with synthetic sample data, 17 October 2026.

## Stage 4: review and approval

Now the draft waits for the manager. Above it, Evenhall says what it is: &quot;AI-generated draft - review
for accuracy before sharing&quot;, next to a button that reads &quot;Approve &amp;amp; lock&quot;. The manager can
download the draft in several formats before approving it, and the export bar says &quot;Approve the
report first for official distribution&quot;. The same bar offers a critic review and a way to refine a
selected passage with AI. Those are separate model calls, which this article does not follow.

When the manager clicks Approve &amp;amp; lock, Evenhall asks once more: &quot;This will lock the report and
prevent further edits. Approved reports cannot be reverted to draft. Are you sure?&quot; After that, the
draft is the company&apos;s report, with the approver&apos;s name and the date.

![Three strips from the sample workspace, numbered 1 to 3. Strip 1 is the draft bar: a warning reads AI-generated draft - review for accuracy before sharing, beside an Approve and lock button; below it, a critic review button, downloads as Markdown, PDF, iXBRL and an auditor package, and the hint Approve the report first for official distribution. Strip 2 is the confirmation dialog, Approve report: This will lock the report and prevent further edits. Approved reports cannot be reverted to draft. Are you sure? with Cancel and Approve and lock. Strip 3 shows the report approved by You on 17 Oct 2026, with the same downloads.](/images/blog/evenhall-ai-sdk-reports/review-approve.webp#screen,726x686 &quot;Review and approval in the sample workspace: (1) the draft with its warning, (2) the confirmation, (3) approved, with the name and the date.&quot;)

Source: TEEI, captured from a local copy of Evenhall with synthetic sample data, 17 October 2026.

The server holds the same line as the screen. It refuses to approve a draft that still has a
citation the check rewrote, and it refuses to approve a report twice. Who decides what, then:

| Decision | Who makes it | What Evenhall does about it |
|---|---|---|
| What the draft says | The model, from the data block and its tool results | Sends the report, drops the notes it recognises, keeps both in the record |
| Whether each cited methodology exists | Evenhall&apos;s check, after the run | Rewrites an unknown citation to `[ungrounded]`, and refuses approval while any remain |
| Whether the draft is right | The manager | Marks the draft as AI-generated and asks for review before sharing |
| Whether it becomes the company&apos;s report | The manager | Approves only after a confirmation, then locks the report |
| Who can read it | The manager | Creates a public link only for an approved report, and removes it when the manager stops sharing |

The AI SDK&apos;s work ended in stage 3. Nothing it returns can approve a report, and Evenhall creates a
public link only for a report the manager has approved.

## Stage 5: the finished report, and sharing it

The approved report opens in Evenhall&apos;s viewer, with a contents list, the report and its details,
and the ways it can leave Evenhall: a PDF, a public link, sharing with an auditor, email, print and
plain text.

![The approved report in Evenhall&apos;s viewer for the sample workspace. The header shows the company, the report type Quarterly Impact Report, the label Approved, the title, and the lines Generated 17 Oct 2026 and Approved by Sample Manager on 17 Oct 2026. On the right are buttons to download the PDF, share, share with an auditor, email, print and download as text, and a box of report details with the type and the status Approved.](/images/blog/evenhall-ai-sdk-reports/viewer.webp#full,phone,1062x371 &quot;The approved report in the viewer, with its details and the ways to share it.&quot;)

Source: TEEI, captured from a local copy of Evenhall with synthetic sample data, 17 October 2026.

In the PDF, each inline citation becomes a numbered note, and the first page records the report&apos;s
id, its status, who approved it and when, and how many figures it links to a source: 63, one for
each citation in the draft.

![Two crops of page 1 of the PDF for the sample report. The first is the cover block: Horizon Technologies (sample) - Quarterly Impact Report (Q3 2026), then the report id, status Approved, approved 17 Oct 2026, approved by Sample Manager, exported 17 Oct 2026, and linked figures 63. The second is the start of the report narrative: the Executive Summary, in which figures such as 15 volunteers, 822.25+ hours and 607 sessions are each followed by a numbered note, 1 to 5.](/images/blog/evenhall-ai-sdk-reports/pdf-page-1.webp#document,689x756 &quot;Page 1 of the PDF: who approved the report and when, the 63 linked figures, and the citations turned into numbered notes.&quot;)

Source: TEEI, rendered by the local copy of Evenhall from the approved sample report, 17 October 2026. Synthetic sample data.

Sharing works the same way. The share button on a draft says &quot;Approve this report before sharing a
public link.&quot;, and the server refuses to create a link for a report that is not approved. For an
approved report, the manager gets a public link to copy and a Stop sharing button, which removes
the link.

![The Share report dialog after approval, with a public link in a field, a Copy button and a Stop sharing button. The link address is blurred.](/images/blog/evenhall-ai-sdk-reports/share-link.webp#screen,766x241 &quot;The share dialog for the approved sample report. We blurred the link, which pointed at the local copy.&quot;)

Source: TEEI, captured from a local copy of Evenhall with synthetic sample data, 17 October 2026.

![The shared report as a reader with the link sees it. The header shows the company, CSR report, the labels Quarterly Impact Report and Approved, the title, and the lines Generated 17 Oct 2026 and Approved by Sample Manager on 17 Oct 2026. On the right, buttons to download the text, share, email and print, and the report details: type, status, generated, approved by and approved on.](/images/blog/evenhall-ai-sdk-reports/shared-page.webp#full,phone,1104x359 &quot;The shared page, as someone with the link sees it.&quot;)

Source: TEEI, captured from a local copy of Evenhall with synthetic sample data, 17 October 2026.

On a phone, in Evenhall&apos;s public demo, the end of the journey looks like this:

![Three Evenhall phone screens from the demo workspace. The first is a draft board summary for August 2026 with its headline figures. The second shows the report awaiting approval, with an Approve button. The third is the report page, with Download PDF, Share, Email, Print and Text, and the report&apos;s details.](/images/blog/how-corporate-volunteering-works/evenhall-report-approve-share-2026-10-23.svg &quot;Evenhall on a phone: a draft summary, the approval waiting for the manager, and the buttons to download or share the report.&quot;)

Source: Evenhall demo workspace, October 2026. The company and its figures are fictional.

What a partner company does with the report is in
[From a Verified Hour to a Report You Can File](/blog/from-a-verified-hour-to-a-report-you-can-file-en/),
and how partnership works from the company&apos;s side is in
[How Corporate Volunteering Works at TEEI](/blog/how-corporate-volunteering-works-at-teei-en/).

## Running it on Cloudflare Workers

The AI SDK needs nothing from Vercel to run. Evenhall is a Worker with
[Node.js compatibility](https://developers.cloudflare.com/workers/runtime-apis/nodejs/) turned on,
and the SDK and its Bedrock provider are bundled into it. The Bedrock and Google providers both
accept a `fetch`, and we give each one our own, which records every model call in our traces with
its request id and provider, and for Bedrock the model and the region. The response-header times
quoted above came from those records.

The local copy ran the same Worker code in workerd, Cloudflare&apos;s open-source Workers runtime,
through the Cloudflare Vite plugin. So the sample run went through the same runtime as production,
on a different machine and network, with local databases in place of ours.

How the rest of Evenhall runs on Workers, with its queues and scheduled jobs, is in
[How TEEI Runs Its Platforms on Cloudflare Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/).

## What we measured, and what we have not

We made two report runs against Bedrock for this article, and one request that the cache answered.
The second run is the one shown above. The first used the same report type and the same data:

| | Run 1 | Run 2, shown above |
|---|---|---|
| Model steps | 3 | 4 |
| Tool calls | 7, then 9 | 7, then 6, then 6 |
| Notes written, and dropped | 3 of 3 | 4 of 4 |
| `metadata` frame | 0.70 s | 0.46 s |
| First text frame | 10.06 s | 11.24 s |
| Last text frame | 68.05 s | 66.88 s |
| `done` frame | 76.92 s | 79.21 s |
| Text frames | 76 | 64 |
| Cited figures recorded after the stream | 40 | 63 |
| Tokens, input and output (SDK count) | 21,274 and 6,123 | 29,523 and 6,380 |

Asked a third time, for the same type and data as run 1, Evenhall answered from its cache in 0.33
seconds, with no model call.

We have not measured how long drafts take in production, how often the model writes notes there,
how many drafts companies have generated, or what a draft costs. We have not evaluated drafts
against reference reports. Two runs on one machine show how the parts fit together and where the
time goes; they are not a benchmark.

## Verify this

You can reproduce both the problem and the filter with the SDK&apos;s own mock model
([testing in the AI SDK](https://ai-sdk.dev/docs/ai-sdk-core/testing)). In an empty folder, run
`npm init -y`, set `&quot;type&quot;: &quot;module&quot;` in `package.json`, run `npm install ai@7.0.92 zod@4.5.4`, save
this as `repro.mjs` and run `node repro.mjs`. It needs no key and no network.

The first step contains a note and a tool call. The second contains the report. The filter at the
bottom is a short version of ours: it knows headings but not our section markers, closes a block
only on two newlines in a row, and drops whatever it still holds if the stream ends without a
`finish-step`.

*Written for this article.*

```js
import { streamText, tool, isStepCount, simulateReadableStream } from &apos;ai&apos;;
import { MockLanguageModelV4 } from &apos;ai/test&apos;;
import { z } from &apos;zod&apos;;

const usage = {
  inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
  outputTokens: { total: 20, text: 20, reasoning: 0 },
};

// One model step: text deltas, an optional tool call, then finish.
function step(deltas, reason, toolCall) {
  const chunks = [
    { type: &apos;stream-start&apos;, warnings: [] },
    { type: &apos;text-start&apos;, id: &apos;t&apos; },
    ...deltas.map((delta) =&amp;gt; ({ type: &apos;text-delta&apos;, id: &apos;t&apos;, delta })),
    { type: &apos;text-end&apos;, id: &apos;t&apos; },
    ...(toolCall ? [toolCall] : []),
    { type: &apos;finish&apos;, usage, finishReason: { unified: reason, raw: undefined } },
  ];
  return { stream: simulateReadableStream({ chunks }) };
}

// Step 1 is working notes and a tool call. Step 2 is the report.
const model = () =&amp;gt;
  new MockLanguageModelV4({
    doStream: [
      step([&quot;I&apos;ll check how hours are counted before writing. &quot;, &apos;Let me look that up.\n\n&apos;], &apos;tool-calls&apos;, {
        type: &apos;tool-call&apos;, toolCallId: &apos;c1&apos;, toolName: &apos;lookupMethod&apos;, input: &apos;{&quot;slug&quot;:&quot;volunteer-hours&quot;}&apos;,
      }),
      step([&apos;## Volunteer time\n\n&apos;, &apos;Hours are counted per session &apos;, &apos;[ref:volunteer-hours].\n\n&apos;,
        &apos;## Next step\n\n&apos;, &apos;A manager reviews this draft before it is filed.\n&apos;], &apos;stop&apos;),
    ],
  });

const tools = {
  lookupMethod: tool({
    description: &apos;Look up how a figure is calculated&apos;,
    inputSchema: z.object({ slug: z.string() }),
    execute: async ({ slug }) =&amp;gt; ({ slug, formula: &apos;sum of session hours&apos; }),
  }),
};

const run = () =&amp;gt; streamText({ model: model(), prompt: &apos;Write the report.&apos;, tools, stopWhen: isStepCount(2) });

// Forward report text only: drop steps that call a tool, hold text until the
// first heading, then release everything up to the last closed paragraph.
const HEADING = /^#{1,3}\s/m;
async function* reportOnly(parts) {
  let buf = &apos;&apos;, started = false, streaming = false, toolCalled = false;
  for await (const part of parts) {
    switch (part.type) {
      case &apos;start-step&apos;:
        buf = &apos;&apos;; streaming = false; toolCalled = false;
        break;
      case &apos;text-delta&apos;: {
        if (toolCalled) break;
        buf += part.text;
        if (!started) {
          const at = buf.search(HEADING);
          if (at &amp;lt; 0) break;
          buf = buf.slice(at); started = streaming = true;
        }
        if (!streaming) break;
        const end = buf.lastIndexOf(&apos;\n\n&apos;) + 2;
        if (end &amp;gt; 1) { yield buf.slice(0, end); buf = buf.slice(end); }
        break;
      }
      case &apos;tool-call&apos;:
        toolCalled = true; buf = &apos;&apos;;
        break;
      case &apos;finish-step&apos;:
        if (!toolCalled &amp;amp;&amp;amp; buf.trim()) { started = true; yield buf; }
        buf = &apos;&apos;;
        break;
      case &apos;error&apos;:
        throw part.error;
    }
  }
}

let all = &apos;&apos;;
for await (const text of run().textStream) all += text;
console.log(&apos;--- result.textStream ---\n&apos; + all);

const result = run();
const sent = [];
for await (const chunk of reportOnly(result.stream)) sent.push(chunk);
console.log(&apos;--- reportOnly(result.stream) ---\n&apos; + sent.join(&apos;&apos;));
sent.forEach((chunk, i) =&amp;gt; console.log(`chunk ${i + 1}: ${JSON.stringify(chunk)}`));

const steps = await result.steps;
console.log(`steps: ${steps.length}, tool output: ${JSON.stringify(steps[0].toolResults[0].output)}`);
```

We ran it on 17 October 2026 at 14:12 UTC with Node 22.23.2, `ai` 7.0.92 and `zod` 4.5.4. The output:

```text
--- result.textStream ---
I&apos;ll check how hours are counted before writing. Let me look that up.

## Volunteer time

Hours are counted per session [ref:volunteer-hours].

## Next step

A manager reviews this draft before it is filed.

--- reportOnly(result.stream) ---
## Volunteer time

Hours are counted per session [ref:volunteer-hours].

## Next step

A manager reviews this draft before it is filed.

chunk 1: &quot;## Volunteer time\n\n&quot;
chunk 2: &quot;Hours are counted per session [ref:volunteer-hours].\n\n&quot;
chunk 3: &quot;## Next step\n\n&quot;
chunk 4: &quot;A manager reviews this draft before it is filed.\n&quot;
steps: 2, tool output: {&quot;slug&quot;:&quot;volunteer-hours&quot;,&quot;formula&quot;:&quot;sum of session hours&quot;}
```

`textStream` has the note on its first line; the filtered stream starts at the heading and arrives
in four blocks. We also ran this stream through our production filter, with four harder ones: a
step with no heading, a lead-in before the heading, a note after a sent paragraph, and a note
finished as its own paragraph before a tool call. Both filters sent the same chunks for all five.

- Read [`streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) and
  [tool calling](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling) in the AI SDK
  documentation, and the [Amazon Bedrock provider](https://ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock).
- Read [how TEEI turns Amazon Chime SDK events into verified hours](/blog/from-practice-room-to-company-report-teei-amazon-chime-sdk-en/),
  where the figures in these drafts begin.
- Read [What TEEI Runs on AWS](/blog/everything-teei-runs-on-aws-en/), including the AI checks that
  call Amazon Bedrock without the AI SDK.
- See [Evenhall](/partners/evenhall/) for how companies take part in TEEI&apos;s programmes.
- If you are building with TEEI, [talk to us](/partners/technology/) and we will walk you through
  the code.&lt;/data&gt;&lt;/data&gt;</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Vercel AI SDK</category><category>Evenhall</category><category>Amazon Bedrock</category><category>Cloudflare Workers</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>When the Website Is Up but the Signup Flow Is Broken</title><link>https://theeducationalequalityinstitute.org/blog/when-the-website-is-up-but-the-signup-flow-is-broken-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/when-the-website-is-up-but-the-signup-flow-is-broken-en/</guid><description>For six days in August 2026, every new volunteer was turned away at the LinkedIn step while the site stayed up. How we tell a quiet door from a shut one.</description><pubDate>Sat, 17 Oct 2026 00:00:00 GMT</pubDate><content:encoded>Volunteers from many countries give their time on TEEI Language, TEEI Mentorship and Skills Academy. TEEI is a nonprofit, and our programmes are free. They are for **Ukrainians affected by the war, both those displaced abroad and those still living in Ukraine, and for women from Latin America and the Caribbean, including women who have migrated or been displaced.**

From the evening of 12 August to the evening of 18 August 2026, every new volunteer who signed in with LinkedIn was turned away when LinkedIn sent them back to us. The website was up. Learners kept signing up, between 2 and 10 a day on TEEI Language. Between 13 and 17 August we deployed TEEI Language to production 14 times, and the check after each deploy reported OK. In those six days, not one new volunteer account was created, and no alarm went off.

The cause was a change on our side to how we check the sign-in that LinkedIn hands back. It rejected every real one. This note is not about that check. It is about how we now tell that kind of silence apart from an ordinary quiet week, using PostHog, our own database and Sentry, each for what it is good at. It follows the volunteer&apos;s path through the programmes, shows the daily numbers, the alarm we built afterwards and the code behind it, and ends with what the same tools showed on 14 October, when no errors were logged and people still stopped.

Screens labelled with a date in August are screenshots from that day. Screens labelled &quot;As it looks today&quot; come from our development server. The people in them are sample profiles. The figures were read on 17 October 2026; their limits are listed at the end, with a public data file.

**Built through volunteer contributions.** TEEI&apos;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.

![Three screens of the volunteer signup in TEEI Language from August 2026. First, the volunteer page on 4 August with a button to start volunteering. Second, the LinkedIn step on 5 August with a button to continue to LinkedIn and a box to accept the terms. Third, the role chooser that a failed return from LinkedIn ended on, with no error message. Under each screen is the instrument that saw that step.](/images/blog/when-the-website-is-up-but-the-signup-flow-is-broken/door-in-august.svg#wide,phone,fit &quot;The volunteer door in August. PostHog saw the visit and the press on LinkedIn; only Sentry saw the failed return, and no alert fired.&quot;)

Source: TEEI, screenshots of TEEI Language taken on 4 and 5 August 2026. On 12 August the LinkedIn step&apos;s heading changed from &quot;Verify your identity&quot; to &quot;Connect your LinkedIn account&quot;, the heading volunteers saw during the outage; the layout, the button and the box stayed the same.

## Who the door is for

![Heidi B., a volunteer language partner in TEEI Language, in a square film. She talks about preparing for each session, discussing business English with her learners, such as how to hold a meeting or present in English, and practising regularly.](https://r2.theeducationalequalityinstitute.org/videos/cockpit/square/heidi-b-1.mp4?v=2026-09-22b#square &quot;Heidi B., a volunteer language partner in TEEI Language, on preparing each session and meeting her learners regularly. 1 min 13 s.&quot;)

Source: TEEI, a film recorded with Heidi B.&apos;s consent; this square cut rendered on 22 September 2026.

A TEEI Language volunteer gives learners regular conversation practice, often about work: meetings, presentations, interviews. A TEEI Mentorship mentor talks through a career with someone who is building one. A Skills Academy instructor teaches a workshop, a clinic or a class in their own field. Some volunteers give their time through their employer, and their hours are counted for the company in Evenhall, TEEI&apos;s free volunteering workspace for companies.

Each of those starts with an account, and for a volunteer the account comes after LinkedIn. Six days without new volunteer accounts meant six days in which nobody new could be approved, open their calendar to learners or add hours to a company&apos;s record.

![Three screens with sample people. A volunteer&apos;s home in TEEI Language with two requests from learners waiting for an answer, a live session in the room on two phones, and Evenhall&apos;s session record with a list of verified sessions. Under each screen is the instrument that watches it.](/images/blog/when-the-website-is-up-but-the-signup-flow-is-broken/behind-the-door.svg#wide,phone,fit &quot;What the door leads to: requests from learners, the session itself, and the hour in a company’s record.&quot;)

Source: TEEI Language demo platform and a live Amazon Chime SDK meeting with demo personas, 14 October 2026, and Evenhall&apos;s public demo workspace with sample data. The demo workspace&apos;s totals are cropped out, because they are sample figures.

## Did the person get through?

Uptime answers &quot;does the page respond?&quot;. Error monitoring answers &quot;did something throw?&quot;. Neither answers the question a volunteer cares about, which is whether they got in. In August the page responded and the errors were logged. The door was still shut.

We had met this kind of failure once before. On 17 July 2026 a volunteer told us that the LinkedIn field in their application would not submit. The browser had rejected a link without &quot;https://&quot; and stopped the form before any of our code ran, so nothing threw, nothing was logged and nothing was counted. The volunteer waited 13 days. That case is why we built a silence alarm at the end of July: a failure that leaves no trace can only be seen as an absence.

An absence can mean three things. The door can be quiet, because few people came. People can come and decide not to go on. Or people can try and be stopped. The rest of this note is about telling those apart, with three instruments that each see part of the path.

## Who sees what, step by step

Table: Which instrument sees each step of the volunteer door today, and what each of them saw in August 2026.

| Step | PostHog | Our database | Sentry | In August |
|---|---|---|---|---|
| The volunteer page | The visit, and where it came from | Nothing | Nothing | Seen by PostHog |
| The LinkedIn step | The step opened, the button pressed | An attempt, since 20 August | Nothing | Seen by PostHog alone |
| Back from LinkedIn | An event when the sign-in is accepted | The return, since 20 August | An error when the sign-in fails | Every failure logged; no rule watching |
| Application and account | The form reached, the account created | The account | The volunteer alarm, since 18 August | No new accounts |
| First booking and session | Bookings and finished sessions | The booking and the session | Errors in booking and the call | No new volunteer to book |
| Hours for a company | Nothing | The session, carried into Evenhall | Nothing | No new hours to count |

PostHog sees the most steps, and it sees how people move: which screen they were on, how long they stayed, what they pressed. Our database sees fewer steps, but it sees every one of them for every person, and it holds the outcome that matters. Sentry sees what goes wrong loudly. In August the only step that told the truth on its own was the account, and nothing was counting accounts by door.

## Six days, drawn

![Five panels of daily bars for TEEI Language, 1 to 25 August 2026, with 13 to 18 August shaded. People who opened the LinkedIn step and people who pressed the LinkedIn button continue through the shaded days. People who reached the application form fall to 0 until 18 August, new volunteer accounts fall to 0 for all six days, and new learner accounts continue at 2 to 10 a day.](/images/blog/when-the-website-is-up-but-the-signup-flow-is-broken/august-by-day.svg#wide,phone,fit &quot;TEEI Language, per UTC day. Interest held while the outcome was zero.&quot;)

Source: TEEI, from PostHog (people per day on each step, production events of TEEI Language only) and from a nightly copy of our database (accounts per day), 1 to 25 August 2026, read on 17 October 2026.

Read it from the top. People kept opening the LinkedIn step, 4 to 8 different people a day, and kept pressing the button: 15 people pressed it 101 times in the six days. The third row falls to zero, because the application form only appears after a return from LinkedIn that we accept. New volunteer accounts fall to zero with it. New learner accounts, in the bottom row, carry on.

Two details in the timings matter if you read error logs. The change reached production on 12 August. The last return we accepted came at 06:24 UTC, and nobody pressed the button again until 22:55 that evening, so the first error was logged at 22:56. At the other end, the errors stopped at 13:33 UTC on 18 August, hours before the fix was live, because nobody tried again until the evening. Returns were accepted again from 18:46 UTC, and the first new volunteer account arrived the next morning. An error log starts and stops when people arrive, not when the code changes.

## Why nothing paged

Our silence alarm had been watching since 4 August. It asked whether new accounts were still arriving in the programme, and they were. Learners sign up with Telegram, WhatsApp or an email address, without LinkedIn, and on every day of the outage some did.

![Two phone screens of TEEI Language as it looks today. On the left, the learner signup in Ukrainian, which asks for a way to be reached and continues without LinkedIn. On the right, the volunteer signup, which asks the volunteer to connect their LinkedIn account before anything else.](/images/blog/when-the-website-is-up-but-the-signup-flow-is-broken/two-doors.svg#phone,fit &quot;Two doors into one programme. A learner account needs no LinkedIn; a volunteer account comes only after it.&quot;)

Source: TEEI Language on our development server, 17 October 2026.

The failure itself was logged. Sentry recorded an error each time a return was rejected, 121 times in all: 3 on the evening of 12 August, then 6, 10, 13, 10, 45 and 34 a day. No alert rule was watching that error until the afternoon of 18 August, when the fix had already been written.

The rule we took from it is short. **A group of people who can go dark on their own has to be counted on their own.** On 18 August we split the volunteer door out of the programme&apos;s count and gave it an alarm of its own.

## Quiet, uninterested or shut

The volunteer door is small. In the twelve days before the outage, TEEI Language had between 0 and 3 new volunteers a day, so a day with none is normal. A day with none tells you nothing until you know whether anyone tried.

![Three cards, one for each reading of a day without a new volunteer in TEEI Language. A quiet week, 20 to 27 July 2026: 0 volunteer accounts in 171.4 hours, 0 to 2 people a day pressed the LinkedIn button, and someone got through on 23 and on 26 July. Pressed, then stopped, five days in July and August: 0 volunteer accounts each day while 6 to 13 learners signed up, 0 to 3 people pressed, and someone got through on 23 July. A shut door, 13 to 18 August 2026: 0 volunteer accounts each day while 2 to 10 learners signed up, 15 people pressed 101 times, and none got through until the fix.](/images/blog/when-the-website-is-up-but-the-signup-flow-is-broken/three-readings.svg#wide,phone,fit &quot;Three readings of a zero. What separates them is not the count of what came out, but whether anyone tried and came back.&quot;)

Source: TEEI, from PostHog and a nightly copy of our database, TEEI Language, July and August 2026, read on 17 October 2026. Returns turned away come from Sentry.

Look at the middle card. On 5 August three people pressed the LinkedIn button and none of them reached the application form. Sentry logged no rejected return that day, and a person can stop at LinkedIn&apos;s own page and close the tab. Pressing is not the same as coming back.

So the alarm counts attempts. This is the reasoning in our code as it stands:

*Verbatim excerpt.*

```ts
/**
 * The conversion signals, and why they are a different shape from everything above.
 *
 * The silence floor answers &quot;did anything arrive&quot;, which cannot separate a shut door from a
 * quiet weekend: measured over 90 days, no threshold on completions alone can. Volunteer
 * interest genuinely reaches zero on real days (2026-07-18, 07-23, 07-24 and 07-28 each had
 * zero volunteers while 6 to 14 learners signed up), so learner traffic does not discriminate
 * either. What does is an ATTEMPT: somebody committed to the door and produced nothing.
 *
 * These need no baseline guard, and that is the property worth keeping. No attempts means no
 * alarm, so a quiet weekend, a fresh staging database and a local checkout are all silent for
 * free. The silence floor needs a 30-day baseline to buy the same thing.
 */
export const FUNNEL_CONVERSION_WINDOW_MS = 24 * 60 * 60 * 1000;
```

Measured again for this note, the most learners on those four days was 13, not 14.

Every press of the LinkedIn button now writes a row in our database, and so does every return from LinkedIn, before anything on our side can turn the person away. The alarm watches two steps within 24 hours: three returns and no new volunteer account, or three presses and no return at all. The second would also fire on a day like 5 August if none of the three came back; we cannot tell whether they did, because the return counter came later. Three is a judgement, and the code says so:

*Verbatim excerpt, with one internal reference cut, marked […].*

```ts
/**
 * THIS NUMBER IS NOT MEASURED, unlike every other constant in this file, and it cannot be:
 * the counter it reads […] has never existed before, so there is no history to
 * replay it against.
 *
 * 3 is chosen so a single abandoned attempt cannot page, while a wholly shut door does within
 * a day. During the 2026-08-12 outage the volunteer signup page was opened 12 to 47 times a
 * day for six days, so a total failure clears 3 on day one with room to spare.
 *
 * DEBT, stated rather than dressed up: re-measure this against real attempt data after two to
 * four weeks and replace this comment with the count it earns. Until then it is a guess, and
 * a reader deserves to know which of these constants is evidence and which is judgement.
 */
export const FUNNEL_CONVERSION_MIN_ATTEMPTS = 3;
```

The 12 to 47 holds up: it counts how often the LinkedIn step was opened on each of the six days, by 4 to 8 people a day. Sentry logged 3 rejected returns on the first evening alone, so the rule as it stands today would have fired within the first day of the outage.

The silence floor stays, as a backstop for a door that fails before anyone can attempt it. Its window comes from a backtest over the 90 days before it was added, 20 May to 12 August 2026, with the outage left out. The first volunteer account through this door in that window came on 6 July, so the backtest covers about five weeks of volunteers.

![Grouped bars of false alarms per silence window, for TEEI Language alone and for both programmes. 24 hours: 11 and 10. 48 hours: 5 and 4. 72 hours: 1 and 1. 192 hours: 0 and 0.](/images/blog/when-the-website-is-up-but-the-signup-flow-is-broken/backtest-windows.svg#wide,phone,fit &quot;False alarms in the backtest, per silence window. At 72 hours the programme scope no longer changes the answer.&quot;)

Source: TEEI, from a nightly copy of our database and PostHog, 20 May to 12 August 2026, outage excluded, measured on 17 October 2026.

At 72 hours the floor fired once, in the quiet week of 20 to 27 July, when 171.4 hours passed without a new volunteer. At 192 hours it would never have fired, but it would also have stayed silent through this outage, which was over before eight days had passed. We chose 72 hours.

Here the note&apos;s own lesson caught us. The window was first measured across both programmes, while the alarm counts TEEI Language alone. Run again for this note with the alarm&apos;s own scope, the false alarms at 24 and 48 hours rise by one each. At 72 hours the choice holds either way. The rule we rejected, &quot;no volunteers while at least five learners sign up&quot;, would have caught the outage on day one as well, but it fired on four quiet days across both programmes, five for TEEI Language alone, against the floor&apos;s one.

## What we built afterwards

Two things changed in the week after. On 20 August the attempt counter arrived: a row when someone presses the LinkedIn button, and another when LinkedIn sends them back. On 24 August a server-side record of the whole signup followed, with one row for each visit to the signup page and one for each step, from landing to approval by our team.

The split between the tools is deliberate. The alarm stands on what happened, in our own database, where every account exists whether or not the visitor accepted analytics. PostHog explains what happened before the stop: which screen, how long people stayed, whether they pressed something that did nothing.

![The LinkedIn step of TEEI Language on a desktop screen, twice: on 5 August 2026, and as it looks today. The layout is the same; under the first, PostHog alone, and under the second, PostHog and an attempt row in our database.](/images/blog/when-the-website-is-up-but-the-signup-flow-is-broken/august-and-now.svg#phone,fit &quot;The same step, then and now. In August only PostHog saw it. Today our database sees every press and every return.&quot;)

Source: TEEI Language, a screenshot of 5 August 2026 and our development server on 17 October 2026, both at 1280 by 720 pixels and cropped to the same area.

## Name the programme in every query

TEEI Language and TEEI Mentorship run from one codebase and send their events to one PostHog project. Skills Academy runs inside TEEI Mentorship, with the same accounts and the same database. Every event carries the programme it belongs to, and Skills Academy&apos;s pages add a marker for their own surface.

![Three phone screens as they look today: the LinkedIn step in TEEI Language, the same step in TEEI Mentorship with the mentor&apos;s wording, and the registration to teach in Skills Academy. Under each, the programme PostHog records.](/images/blog/when-the-website-is-up-but-the-signup-flow-is-broken/three-doors.svg#wide,phone,fit &quot;Three doors in two programmes. Each event says which door it came through.&quot;)

Source: TEEI Language, TEEI Mentorship and Skills Academy on our development server, 17 October 2026.

In a September audit of booking availability, a query without a programme filter counted 189 attempts. 163 were TEEI Language and 26 were TEEI Mentorship. Every success in the sample was in TEEI Language, so the blend did not show until we split it. Since then, our queries name the programme, and the ones behind this note run a second time without that clause, so the parts can be checked against the whole.

This is the query behind the PostHog rows in this note:

*Adapted for publication.*

```sql
select toDate(timestamp) as day,
       properties.product as programme,
       multiIf(event = &apos;signup_viewed&apos; and properties.step = &apos;linkedin_gate&apos;, &apos;opened&apos;,
               event = &apos;signup_linkedin_started&apos;, &apos;pressed&apos;,
               event = &apos;signup_viewed&apos; and properties.step = &apos;apply&apos;, &apos;through&apos;,
               &apos;other&apos;) as kind,
       count() as events,
       uniq(person_id) as people
from events
where event in (&apos;signup_viewed&apos;, &apos;signup_linkedin_started&apos;)
  and properties.role = &apos;volunteer&apos;
  and properties.environment = &apos;production&apos;
  and properties.product in (&apos;language&apos;, &apos;mentorship&apos;)
  and timestamp &amp;gt;= toDateTime(&apos;2026-08-01&apos;) and timestamp &amp;lt; toDateTime(&apos;2026-09-01&apos;)
group by day, programme, kind
order by day, programme, kind
limit 10000
```

Run it again without the line on `properties.product`, and check that on every day and for every kind the two programmes&apos; events add up to the total. People do not add up the same way, because one person can appear in both programmes. For this note we ran it a month at a time, from 1 July to 15 September 2026, and the events added up on all 77 days.

Two things about HogQL caught us while writing it. Through the API, a query without its own `LIMIT` returns at most 100 rows. PostHog&apos;s [query documentation](https://posthog.com/docs/api/queries) says so (&quot;By default, API queries return up to 100 rows.&quot;), but a cut-off result looks like a complete one, and a month of days, programmes and steps is longer than that. Our first sum check failed for that reason. And `step` is a reserved word, so it cannot be used as a column alias.

## What a no looks like

Not every stop is a fault. On 14 October we went through every signup that had started from our volunteer role board since the first one, on 21 September. That review counted 33 on the server, and 29 people once two of our own tests and two duplicates were taken out. 13 came through.

Of the 16 who stopped, the review found a screen in PostHog for 9. Eight of those nine stopped at LinkedIn. Five left the LinkedIn step without ticking the box, four of them within 1 to 4 seconds. Two went to LinkedIn and never came back, both of them mentors. One came back and went no further. The ninth left the Skills Academy screen &quot;Who you will teach&quot; after 3 seconds. There were no errors and no failed submissions.

This is what a no looks like to the tools: no error, no return, a short visit. They cannot tell us why these people left, and we did not ask. The alarm stays silent, as it should. What we can work on is the screen.

## What PostHog sees, and what it does not

From 15 July to 15 September 2026, 660 learners signed up through TEEI Language&apos;s signup page. Our server sent PostHog an account event for 659 of them, 99.8 percent. PostHog&apos;s browser library left a trace for 615, 93.2 percent; for the other 45 we found no trace in the browser, and the figures do not say why. For volunteers the figures were 83 and 78 of 84. In those weeks, the browser alone would have missed about one person in fifteen.

On our website, TEEI Language, TEEI Mentorship and Skills Academy, PostHog runs only after a visitor accepts analytics. Everyone who declines is missing from PostHog by design, and is in our database like everyone else. That is the strongest reason the alarm lives in our database.

*Adapted for publication.*

```js
// The PostHog snippet runs on every page, opted out and without storage.
posthog.init(POSTHOG_KEY, {
  api_host: POSTHOG_HOST,
  opt_out_capturing_by_default: true,   // send nothing until the visitor accepts
  opt_out_persistence_by_default: true, // and store nothing
  person_profiles: &apos;identified_only&apos;,
  autocapture: true,
  mask_all_text: true,                  // clicks carry no text from the page
  capture_pageview: false,              // the router sends each page view itself
  respect_dnt: true,
  disable_session_recording: true,      // switched on per page, after consent
  session_recording: {
    maskAllInputs: true,
    maskTextSelector: &apos;*&apos;,
    blockSelector: &apos;img,picture,video&apos;,
  },
});

// &quot;Accept&quot;: opt in, then load the library.
posthog.opt_in_capturing();
window.dispatchEvent(new Event(&apos;lc:posthog-ready&apos;));

// &quot;Decline&quot;, or a later withdrawal: stop recording, opt out,
// and remove PostHog&apos;s cookie and stored data.
posthog.stopSessionRecording();
posthog.opt_out_capturing();
```

Two settings live in the PostHog project rather than in the code. Since 12 September 2026 it discards IP addresses and adds no location. Recordings mask all text and everything typed, block images and video, and are never made in a video room, on the page that leads into one or on a sign-in link. The account event our server sends is covered by our interest in keeping signup working, and anyone can object to it; our [privacy policy](/privacy-policy/) says how.

## Teach what you use at work

Nothing in this note depends on one product. Count any group that can go dark on its own separately. Put the alarm on an attempt that produced nothing, not on a quiet day. Name the programme in every query, and check that the parts add up. If you work with a tool like PostHog, you already know most of this, and you can teach it.

Skills Academy is where TEEI&apos;s volunteers teach. An instructor offers a Workshop, a Clinic, an Expert Q&amp;amp;A, a Webinar or a Class in their own field, TEEI staff approve each instructor, and TEEI brings the learners from its programmes. A clinic on reading a signup funnel, or a workshop on telling a quiet week from a broken step, is the kind of practical session we mean. Companies that build a tool can also offer a Workshop or a Class about their own product.

## About the figures

- PostHog counts are production events, split by programme, and are for TEEI Language unless a label says otherwise. &quot;People&quot; are distinct people on one day; they do not add up across days. On every day from 1 July to 15 September 2026, the events of TEEI Language plus TEEI Mentorship equal the count without a programme filter; people are not summed, since one person can appear in both. Read on 17 October 2026 at 17:38 UTC.
- Account counts come from a nightly copy of our database taken on 17 October 2026. Test and monitoring accounts are left out. Accounts deleted since they were created are not in the copy, so some early days can be a little low.
- The backtest covers 20 May to 12 August 2026, with the outage left out. Volunteer accounts that came through the door and have since been deleted are added back from PostHog&apos;s record of their creation: two across both programmes, one of them in TEEI Language. Counted across both programmes, as when the alarm was built, the backtest gives the figures written down then: 10, 4, 1 and 0. The database copy alone, for TEEI Language, gives 10, 5, 2 and 0.
- The errors are Sentry&apos;s count of rejected returns, read on 17 October 2026. A person who tried again is counted again.
- The 14 October review covers signups from the role board between 21 September and 14 October 2026 at 12:11 UTC. Read again for this note, the 33 rows and the stops at each screen matched. The two tests were identified on 14 October from request logs we did not read again, so the 29 people and the 9 screens rest on that review.
- Coverage compares the accounts in our database with PostHog&apos;s events from 15 July to 15 September 2026, matched within ten seconds.
- The daily counts for 1 July to 15 September 2026, the backtest and the coverage figures, with their definitions, are in a [public data file](/data/signup-door-figures-2026-10-17.json). It holds counts only, with no names, email addresses, account references or PostHog identifiers.
- To check the method against your own project, run the query above with and without the programme clause.

## Next steps

- **Teach what you use.** [Teach in Skills Academy](https://skills.theeducationalequalityinstitute.org/signup?role=instructor). Teach a workshop, a clinic or a class in your field. TEEI brings the learners.
- **Give your time to a learner.** [Become a language volunteer](https://language.theeducationalequalityinstitute.org/signup?role=volunteer) on TEEI Language, through the door this note is about.
- **Build with us.** [Talk to us about building with TEEI](/partners/technology/) if your team&apos;s technology could serve learners and volunteers.</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>PostHog</category><category>Product analytics</category><category>Monitoring</category><category>Sentry</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>DataCamp in TEEI&apos;s Programmes: A Thank You</title><link>https://theeducationalequalityinstitute.org/blog/datacamp-in-teei-programmes-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/datacamp-in-teei-programmes-en/</guid><description>Learners in TEEI&apos;s group study on DataCamp through DataCamp Donates. How the platform works, where it sits in TEEI&apos;s programmes, and our thanks.</description><pubDate>Fri, 16 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&amp;gt; &quot;My work was on hold. I had to do something to cope somehow with the fear of uncertainty. Thanks to DataCamp I realised this was an interesting direction for me. … Right now I work in this field.&quot;
&lt;span&gt;&lt;/span&gt;

&lt;cite&gt;Svitlana&lt;/cite&gt;, learner in TEEI&apos;s DataCamp group, translated from Ukrainian.

Source: Learner survey, autumn 2026.

&lt;p&gt;Робота була на паузі. Треба було щось робити, щоб якось справлятися зі страхом невизначеності. Завдяки DataCamp я зрозуміла, що це цікавий напрям для мене. … Наразі я працюю в цьому напрямку&lt;/p&gt;

Svitlana is one of the learners in TEEI&apos;s group on DataCamp. Since March 2022 they have studied there at no cost to them, through DataCamp Donates. Here is the group in numbers, each with the day it was read.

![Six numbers in two rows. Hours on the platform 77,374, plus 2,428 in the last 30 days, since March 2022, read 7 October 2026. Members who earned XP 2,152, plus 99 in the last 30 days, read 7 October 2026. Courses completed 15,979, counted once per learner. Learners who completed courses 1,576, at least one course each. Projects completed 2,830, guided and unguided. Certifications 193, granted to 121 learners. The last four cover 31 October 2023 to 12 October 2026.](/images/blog/datacamp-thank-you/stat-band.svg#wide,phone,fit &quot;TEEI’s group on DataCamp in numbers.&quot;)

Source: TEEI&apos;s group dashboard on DataCamp, read on 7 October 2026 at 17:20 GMT+2, and DataCamp&apos;s daily data for TEEI&apos;s group, 31 October 2023 to 12 October 2026, synced on 12 October 2026.

## How DataCamp works

DataCamp is its own learning platform, outside TEEI&apos;s sites. DataCamp builds and runs the courses, the code editor, the exercises, the tracks, the projects and the certifications. A course has lessons and exercises. In a coding exercise, the learner writes code in DataCamp&apos;s editor and submits it. If she is stuck, she can take a hint or show the answer, and she then earns less XP, or none.

DataCamp uses AI to generate practice exercises from the course content, usually five to a workout, with instant feedback, as [DataCamp&apos;s help centre](https://support.datacamp.com/en-us/articles/17598845-datacamp-practice-reinforce-your-learning-with-hands-on-exercises) describes it. A track sets courses in sequence toward one skill or one role, and a finished course or track gives a Statement of Accomplishment. A project applies the skills to a real-world problem, with step-by-step guidance or without it. A certification is DataCamp&apos;s own exam. It covers a role such as data analyst, a technology such as SQL, or a theory area such as AI Fundamentals.

![DataCamp&apos;s public exercise page for Introduction to Python, chapter 1, exercise 6, shown in three parts with numbered notes. One, the top of the page names the course the exercise belongs to. Two, the exercise, where she writes code in DataCamp&apos;s editor, runs it and submits it, with the Edit and Run Code button. Three, the course block with its four chapters. Under them, two rows. Inside a course: an exercise sits in a chapter in a course, and practice is made at chapter and course level, about five AI-generated exercises to a workout. Next to the courses: tracks, courses in sequence toward one skill or one role; projects, guided or unguided; certifications, not based on one course.](/images/blog/datacamp-thank-you/platform.svg#wide,phone,fit &quot;DataCamp’s platform, outside TEEI: an exercise page as DataCamp shows it, and how the parts fit.&quot;)

Source: DataCamp&apos;s public exercise page, captured on 16 October 2026 without signing in and shown in parts, and DataCamp&apos;s help centre, read on 16 October 2026.

Through DataCamp Donates, DataCamp gives the learners in TEEI&apos;s group the full curriculum, including new courses as they are added, and the certifications. They also have premium access to DataLab, DataCamp&apos;s online IDE.

&amp;gt; &quot;First of all, thank you for the opportunity to study for free on this wonderful platform, and second, its main advantage, as I see it, is the tracks to certificates, thanks to which I can see where I am heading. Because before this I studied for the sake of studying, and often it did not lead to anything useful.&quot;
&lt;span&gt;&lt;/span&gt;

&lt;cite&gt;Yehor&lt;/cite&gt;, learner in TEEI&apos;s DataCamp group, translated from Ukrainian.

Source: Learner survey, autumn 2026.

&lt;p&gt;По-перше дякую вам за можливість безкоштовно навчатися на цій чудовій платформі, а по-друге її головна перевага, як на мене, це трекі до сертифікатів завдяки яким є бачення куди я рухаюсь. Бо до цього я навчався заради навчання і часто це ні до чого корисного не привело.&lt;/p&gt;

## What the learners did there

DataCamp&apos;s daily data for the group runs from 31 October 2023 to 12 October 2026. The learners have studied in every full month of it.

![Bars for 34 months, January 2024 to October 2026, with the year totals over them: 2024 24,071 hours, 2025 32,894 hours, 2026 17,717 hours to 12 October 2026. The highest bar is February 2025, 3,687 hours from 550 learners. October 2026 is pale, 762 hours counted to 12 October 2026.](/images/blog/datacamp-thank-you/hours-per-month.svg#phone,fit &quot;Hours on the DataCamp platform per month, January 2024 to 12 October 2026.&quot;)

Source: DataCamp&apos;s daily data for TEEI&apos;s group, 31 October 2023 to 12 October 2026, synced on 12 October 2026. October 2026 covers 1 to 12 October 2026.

Learners choose their own courses, and the choices cluster. In that window, 1,576 learners completed 15,979 courses between them, each course counted once per learner. Data manipulation and programming lead by topic, and Python and SQL by tool.

![Two ranked lists of bars. Completed courses by DataCamp&apos;s topic, led by data manipulation and programming, then artificial intelligence, data visualization, data engineering. Completed courses by tool, led by Python and SQL, then theory with no tool, Power BI, Tableau, Excel. Eight of each shown.](/images/blog/datacamp-thank-you/topics-tools.svg#phone,fit &quot;What the 15,979 completed courses were about, by DataCamp’s topic and by tool.&quot;)

Source: DataCamp&apos;s daily data for TEEI&apos;s group, 31 October 2023 to 12 October 2026, synced on 12 October 2026.

Many learners go beyond single courses. In the same window, 218 learners completed a career track and 293 a skill track, and DataCamp granted 193 certifications to 121 learners.

![Three ranked lists of bars, the four most common of each. Career tracks, 218 learners, led by Associate Data Analyst in SQL. Skill tracks, 293 learners, led by SQL Fundamentals. Certifications, 193 to 121 learners, led by Data Analyst Associate.](/images/blog/datacamp-thank-you/tracks-certs.svg#phone,fit &quot;Tracks and certifications: how many learners finished each, and how many certifications DataCamp granted.&quot;)

Source: DataCamp&apos;s daily data for TEEI&apos;s group, 31 October 2023 to 12 October 2026, synced on 12 October 2026.

## Where DataCamp sits in TEEI&apos;s programmes

The self-paced study happens on DataCamp, on DataCamp&apos;s own site. Around it, TEEI runs programmes where a learner uses what she learns with other people. A live session with a professional, a conversation with a volunteer, a mentor. She can use any of them alongside DataCamp, in any order, or on their own.

![A dark band with DataCamp&apos;s logo: self-paced, on DataCamp&apos;s own site, the full curriculum through DataCamp Donates, outside TEEI. Under it, three columns, each with a screen from TEEI&apos;s platform. Skills Academy, a workshop page: live sessions with a professional, workshops, clinics, expert Q&amp;amp;As. TEEI Language, a one-to-one conversation room: conversation in English with a volunteer, one to one or in a Speaking Club, and a line that TEEI Language Courses, online classes with a teacher, are a separate offer. TEEI Mentorship, a learner&apos;s home page: a mentor who helps her choose what to learn next. A line below: in any order, none of it is a ladder. The screens show sample data.](/images/blog/datacamp-thank-you/places.svg#wide,phone,fit &quot;Where DataCamp sits in TEEI’s programmes. A learner can use any of them alongside DataCamp, in any order, or on their own. None of it is a ladder.&quot;)

Source: TEEI&apos;s programme descriptions, October 2026. Screens: demo personas on TEEI&apos;s demo platform, sample data.

### Skills Academy

Skills Academy is TEEI&apos;s learning environment for professional skills. DataCamp is one of the self-paced resources in it. A learner asks for access from her Skills Academy account. A person on the TEEI team adds her to the group, and the invitation comes from DataCamp.

The other side of the Academy is live. Professionals teach workshops, clinics, expert Q&amp;amp;As, webinars and classes in their own field, in TEEI&apos;s video room. That is where she brings her DataCamp work and her questions. On DataCamp she learns the method. In the room she hears how a professional uses it at work.

### TEEI Language

A result is one thing. Explaining to a colleague, in English, how she got there is another.

In TEEI Language, a learner practises that with a person. In a one-to-one conversation with a volunteer she brings her own topic, and a finished DataCamp project is a good one. In a Speaking Club, a host sets the topic for the group. TEEI Language Courses are a separate offer: online language classes with a teacher, with content from Babbel and Oxford University Press.

### TEEI Mentorship

A mentor in TEEI Mentorship is a professional with experience in the field a learner is moving towards. A mentor helps her choose what to study on DataCamp, apply it to real work and plan the next step. Later, the mentor helps her show what she can do, in a CV or an interview. She does not need a finished course to book one.

&amp;gt; &quot;[DataCamp] really did play a very big role; I am endlessly grateful for this free access. It helped me a lot to develop useful skills for data analytics; thanks to it I got an internship, and now I keep learning and am already looking for a junior position.&quot;
&lt;span&gt;&lt;/span&gt;

&lt;cite&gt;A learner in TEEI&apos;s DataCamp group&lt;/cite&gt;, translated from Ukrainian.

Source: Learner survey, autumn 2026.

&lt;p&gt;Відіграв же дуже велику роль, я безмежно вдячна за цей безкоштовний доступ. Це дуже допомогло мені розвинути корисні навички для аналітики даних, завдяки цьому я отримала стажування і зараз продовжую навчатись і вже шукаю позицію junior.&lt;/p&gt;

## What the learners say

In TEEI&apos;s learner survey this autumn, we asked what had been most useful toward their goal, and they could pick several answers. Of the 157 who answered, 130 picked the free access. Asked how likely similar learning would have been without it in that period, 105 said somewhat or very unlikely.

![Two parts. First, four bars with the number who chose each answer as most useful toward their goal, several answers allowed: free access 130, learning at my own pace 103, a clear course sequence or track 78, practical exercises 69. Second, one bar over the 157 answers to how likely similar learning would have been without free access: likely 37, not sure 15, unlikely 105, with a bracket under the last: 105 of 157, somewhat or very unlikely.](/images/blog/datacamp-thank-you/survey.svg#phone,fit &quot;What helped the learners most, and whether they would have pursued similar learning without free access.&quot;)

Source: TEEI&apos;s learner survey of the DataCamp group, 157 learners answering the questions shown, read on 16 October 2026.

&amp;gt; &quot;The biggest change for me is the move from ‘I am learning a tool’ to ‘I am solving a business problem with data’.&quot;
&lt;span&gt;&lt;/span&gt;

&lt;cite&gt;Ivan&lt;/cite&gt;, Data/Product Analyst, learner in TEEI&apos;s DataCamp group, translated from Ukrainian.

Source: Learner survey, autumn 2026.

&lt;p&gt;Найбільша зміна для мене – перехід від позиції «я вчу інструмент» до «я вирішую бізнес-проблему за допомогою даних».&lt;/p&gt;

In 2024 another learner in the group spoke to the camera about studying data analysis on DataCamp during the war.

![A learner from Ukraine speaks to the camera about studying data analysis on DataCamp](https://r2.theeducationalequalityinstitute.org/videos/datacamp/horizontal/datacamp-video-2.mp4#square &quot;A learner in TEEI’s DataCamp group, in their own words. The film runs 40 seconds.&quot;)

Source: TEEI film library, published by TEEI on 22 June 2024.

## Thank you

&amp;gt; &quot;I gained confidence: this is mine! I sincerely thank TEEI for the free access to DataCamp and for the other opportunities and support. It is truly a valuable gift that lets me invest in my own development and learn on a platform I could not always afford.&quot;
&lt;span&gt;&lt;/span&gt;

&lt;cite&gt;A learner in TEEI&apos;s DataCamp group&lt;/cite&gt;, translated from Ukrainian.

Source: Learner survey, autumn 2026.

&lt;p&gt;З’явилась впевненість - що це моє! Щиро дякую TEEI за безкоштовний доступ до DataCamp та інші можливості й підтримку. Це справді цінний подарунок, який дозволяє інвестувати у власний розвиток і навчатися на платформі, доступ до якої я не завжди міг би собі дозволити.&lt;/p&gt;

![The DataCamp Donates logo beside the number 77,374: hours on the DataCamp platform in TEEI&apos;s group, the learners&apos; own work, March 2022 to 7 October 2026.](/images/blog/datacamp-thank-you/thanks.svg#phone,fit &quot;DataCamp Donates, and the hours it made possible.&quot;)

Source: TEEI&apos;s group dashboard on DataCamp, read on 7 October 2026 at 17:20 GMT+2.

To everyone at DataCamp behind DataCamp Donates: thank you. Since March 2022 you have opened the whole platform to TEEI&apos;s learners, at no cost to them. Around that access, professionals, volunteers and mentors give their time in TEEI&apos;s programmes.

The longer version, with how each part fits together, is in [How TEEI connects DataCamp learning with live teaching](/blog/how-teei-connects-datacamp-learning-en/).</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><readingTime>8 min</readingTime><category>teei-updates</category><category>DataCamp</category><category>DataCamp Donates</category><category>Skills Academy</category><category>TEEI Language</category><category>TEEI Mentorship</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>Why We Keep libSQL Clients Inside Each Worker Request</title><link>https://theeducationalequalityinstitute.org/blog/a-shared-database-client-across-worker-requests-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/a-shared-database-client-across-worker-requests-en/</guid><description>A libSQL client shared across Worker requests hung requests in our test lane. We moved the cache into each request and kept reuse within it.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>Until 9 September 2026 we cached one libSQL client per database URL at module scope in Evenhall, our React Router 8 application on Cloudflare Workers, and handed it to every request that asked for that URL. In a local run with one browser client at a time, the pages worked. With several in parallel, our test lane recorded requests the runtime cancelled as hung, warnings about promises resolved from another request&apos;s context, and HTTP 500 responses. We moved the cache into each request with `AsyncLocalStorage` and kept reuse within the request. We also had to preserve which calls received the same client, and our first attempt did not.

Evenhall is the workspace where a partner company&apos;s managers see what their employees did through TEEI&apos;s programmes, and turn it into a report they can file. The figures are built from the sessions that employees held as volunteers in TEEI Language, TEEI Mentorship and Skills Academy, recorded against their company; [How corporate volunteering works at TEEI](/blog/how-corporate-volunteering-works-at-teei-en/) explains how a session is counted. Every page a manager opens, from the impact overview to the monthly report, is a request to Evenhall&apos;s Worker, and the figures on it are read through one resolver, `getClient`. As of 17 October 2026, that data is in Turso, reached through `@libsql/client/web` over HTTP. [How TEEI runs its platforms on Cloudflare Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/) describes the account, the Workers and the database it sits beside.

**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 we saw

We found this on 9 September 2026 in our browser test lane, not through a user report, and our evidence documents no production incident. In that lane we drive a candidate build with Playwright and several browser clients in parallel. After the change, under the same load and serving setup, it recorded no cancellations, no cross-request warnings and no HTTP 500 responses; the totals from before stay internal. That controlled comparison is our evidence for the cause, not a trace of the client&apos;s internals.

## Why a shared client breaks on a Worker

A Workers isolate is reused, and [it can serve several requests at once](https://developers.cloudflare.com/workers/reference/how-workers-works/): &quot;a single Workers instance may handle multiple requests including concurrent requests in a single-threaded event loop&quot;. A map at module scope therefore outlives the request that filled it, as [the Workers best practices](https://developers.cloudflare.com/workers/best-practices/workers-best-practices/) warn: &quot;A variable set during one request is still present during the next.&quot; I/O, however, belongs to a request. [The Workers error reference](https://developers.cloudflare.com/workers/observability/errors/) says that I/O objects &quot;created in the context of one request handler cannot be accessed from a different request&apos;s handler&quot;, and names caching an I/O object in global scope as the most common cause. Since the compatibility date 2024-10-14, &lt;a href=&quot;https://developers.cloudflare.com/workers/configuration/compatibility-flags/&quot;&gt;&lt;code&gt;handle_&lt;wbr&gt;&lt;/wbr&gt;cross_&lt;wbr&gt;&lt;/wbr&gt;request_&lt;wbr&gt;&lt;/wbr&gt;promise_&lt;wbr&gt;&lt;/wbr&gt;resolution&lt;/code&gt;&lt;/a&gt; is on by default, and with it &quot;promise continuations are scheduled to run in the correct request context if it is still alive, or dropped with a warning if the correct context has already ended.&quot; Evenhall&apos;s compatibility date is 2026-04-01, so the flag is on.

A libSQL client over HTTP is a set of `fetch` calls and the promises around them. The commit that fixed our code explains what happens when one is shared: its Hrana promises belong to whichever request first touched it, so a later request awaiting them trips the runtime guard, the continuations are cancelled and the shared client never answers again. Hrana is the protocol the libSQL client speaks over HTTP. In that explanation the awaiting handler never resumes, and the runtime cancels the request as hung. We recorded the warnings and the cancellations, but we did not trace the client&apos;s internal promise chain, or which continuation led to which cancelled request.

![Two sequence diagrams, one above the other. The upper one shows the causal model described in the fix commit, not a traced promise chain. Above, before the fix, request A creates a database client and stores it in a map at module scope, request B later gets the same client, waits on a promise that belongs to request A, and the runtime drops the continuation with a warning, so request B never answers. Below, after the fix, requests A and B each run inside their own scope with their own map and get separate clients, and code outside a scope uses the module map.](/images/blog/a-shared-database-client-across-worker-requests/shared-vs-per-request.svg#wide,phone,fit &quot;One client shared across requests, and one map per request. The upper diagram is the explanation in the commit that fixed it, matched by our before and after in a test lane on 9 September 2026, not a trace from production.&quot;)

Source: TEEI, drawn from our platform code on 16 October 2026.

A shared client also shares its concurrency limiter: in `@libsql/client` 0.18.0, as we read it on 15 October 2026, each HTTP client has its own, with [a documented default](https://tursodatabase.github.io/libsql-client-ts/interfaces/Config.html#concurrency) of 20 concurrent requests. We have not shown whether it contributed; our before and after cannot separate it from the dropped continuations.

The cache we had looked like this:

*Adapted for publication.*

```ts
const clients = new Map();
// ...
  const existing = clients.get(url);
  if (existing) return existing;
  const client = createClient({ url, authToken });
  clients.set(url, client);
  return client;
```

Before 9 September 2026, we also kept two module-level clients: one for OAuth state and one for support form submissions. The first request that asked created each client, and later requests in the isolate reused it.

## One registry per request

We kept the cache and moved it from the module into the request. Node&apos;s `AsyncLocalStorage` gives every asynchronous operation started inside `run(store, callback)` access to that store through `getStore()`, and Workers implement it ([Cloudflare&apos;s AsyncLocalStorage reference](https://developers.cloudflare.com/workers/runtime-apis/nodejs/asynclocalstorage/)). Outside a context started with `run()`, `getStore()` returns `undefined`. That gives us both behaviours we need: a fresh map for every request, and the old module map for code that does not run inside one, such as scripts and unit tests.

This is the first version, as we committed it on 9 September:

*Adapted for publication.*

```ts
import { AsyncLocalStorage } from &quot;node:async_hooks&quot;;
import { createClient, type Client } from &quot;@libsql/client/web&quot;;

const clients = new Map();
export const requestScope = new AsyncLocalStorage&amp;gt;();

export function runWithRequestDbScope(fn: () =&amp;gt; Promise): Promise {
  return requestScope.run(new Map(), fn);
}
// ...
  const cache = requestScope.getStore() ?? clients;
  const existing = cache.get(url);
  if (existing) return existing;
  const client = createClient({ url, authToken });
  cache.set(url, client);
  return client;
```

Within a scope we create and cache the client synchronously, with no `await` between the lookup and the insertion, so every later call for that URL in the scope gets the same object. We add no client cleanup to the wrapper, and we have not measured its time or memory cost.

Evenhall&apos;s Worker passes each request through a middleware chain of our own before the React Router handler runs. The chain now runs inside the scope:

*Adapted for publication.*

```ts
export async function runMiddleware(request, env, ctx, next) {
  // ... the URL, method, start time and request id are read first ...
  return runWithRequestDbScope(async () =&amp;gt; {
    // ... the middleware chain and the React Router handler, unchanged ...
  });
}
```

We enter the scope before middleware and routes look up their clients. The scope selects a registry for later lookups; it does not replace `Client` references a caller already holds. The resolver reads the active map wherever it runs in that asynchronous chain, so no client is passed through the calls. Every call to `runWithRequestDbScope`, nested or not, starts a new map, so the scope belongs around the request and not around each lookup.

Outside a scope, `getClient` silently uses the module map. That is what scripts and tests want, and it is what a request handler must never get. Every request path that uses this resolver has to run inside a scope; in Evenhall, the shared middleware opens it, and the resolver itself does not report a missing scope. The scope therefore covers HTTP requests: the Worker&apos;s `fetch` handler passes each one to that middleware. The same Worker&apos;s `queue()` and `scheduled()` handlers call the same resolver without opening a scope, so they use the module map, and a later invocation in the same isolate can reuse a client an earlier one created. The per-request isolation in this note applies to HTTP requests; we have not examined whether those handlers need a scope of their own.

On a page, the reuse looks like this. When a manager opens the impact overview, its loader reads the company and then starts four independent data helpers at once with `Promise.all`. None of them is handed a client: a helper that reads the database calls `getClient` itself and names the company. They resolve the same URL, so inside the request&apos;s scope they share one client, and another manager&apos;s request, which the same isolate may be serving at that moment, gets its own. The contribution record and the monthly report read through the same resolver.

![Three phone screens from Evenhall&apos;s public demo, with sample data, each with a numbered label. One, the impact overview: 4,932.50 verified hours your people gave to 1,066 learners this year; one request runs the layout&apos;s and the overview&apos;s loaders in parallel, and the overview&apos;s loader starts four helpers together; reads for the same database share one client within the request. Two, the contribution record: 888 colleagues at the sample company Horizon Technologies Holdings have held 3,561 sessions; its loader resolves the client once, naming the company, and hands it to each read in turn. Three, the report for October 2026, a month still open: 690 sessions, 895.25 volunteer hours and 461 people; the same loader, which also reads the source the month&apos;s figures are built from.](/images/blog/a-shared-database-client-across-worker-requests/evenhall-pages-2026-10-23.svg#wide,phone,fit &quot;Three Evenhall pages a manager opens, each one request to the Worker, and what that request asks of the database client.&quot;)

Source: TEEI, captured from Evenhall&apos;s public demo on 23 October 2026. The company and every figure are sample data.

The screens come from Evenhall&apos;s [public demo](https://evenhall.theeducationalequalityinstitute.org/demo/), which runs the same pages and the same resolver against its own database of sample data, so no partner&apos;s data is in it.

## What one page asks of the client

The four helpers are only part of the overview&apos;s request. Opening the Impact tab runs the loader of every route it matches, and React Router runs them in parallel. Two of them read the database: the manager layout&apos;s, which draws the sidebar and the top bar, and the overview&apos;s own, whose data the Impact, People, Operations and Compliance tabs all read. The root route&apos;s loader reads only the request and the environment, and the Impact tab&apos;s own loader returns nothing.

Both loaders need the signed-in manager and the company, and we keep that work per request as well, under a different key. A `WeakMap` at module scope maps each `Request` object to the promise of that work. The first loader to ask starts it, the second awaits the same promise, and a failure removes the entry so that a later call can try again. One test runs the layout&apos;s loader and the shared lookup together on the same `Request` and checks that the sign-in check and the company lookup each run once; another gives them two requests and checks that each runs twice. A promise shared this way never crosses a request: both loaders belong to the request that created it, which is what the runtime allows, and the opposite of a client created in one request and awaited in another. Keying by the `Request` works because every loader is handed it. The helpers further down are not, which is why `getClient` finds its registry through `AsyncLocalStorage` instead.

Then come the reads. For a partner company, outside the demo, and as the code stood on 17 October 2026, the layout&apos;s loader sends one statement for the counts behind the sidebar&apos;s badges and one for the number of workspaces the manager can switch between. The overview&apos;s loader reads the company&apos;s reporting settings and then starts its four helpers. The headline figures send five statements at once, the strategic figures five, and the trust summary for the Compliance tab four, while the Impact tab&apos;s opening ledger sends its four statements as one batch. When all four have returned, three reads for the action inbox go out together. The four helpers therefore start fourteen single statements and one batch at the same moment. Every one of these calls resolves the same URL, so they share one client, and its limiter is shared only with the rest of the same request. A client cached for the whole isolate would put two managers&apos; overviews behind one default of 20.

Each helper also carries its own `.catch`. A helper that fails falls back to empty figures for its part of the page, and the rest still renders. Those fallbacks cover a read that fails; they cannot cover one that never answers. A `.catch` runs when a promise rejects, and a continuation the runtime drops never settles either way. A client that stops answering therefore does not reduce the overview to empty panels: the request waits until the runtime cancels it.

The contribution record and the monthly report share one loader, and it takes the other approach. It resolves the client once, naming the company, and hands that client explicitly to its reads in turn: the record itself, then the basis for the period the page shows, and, for the report, the source the month&apos;s figures are built from. Each of those reads has its own fallback, so a failed basis or report says what is unavailable instead of blanking the page. Naming the company is what sends the public demo to its own database of sample data; without it, the call would resolve the primary database for every company.

The registry still matters on that page. Before it resolves the client, the loader looks the company up, and that lookup calls `getClient` itself. For a partner company it resolves the same URL, so the client the loader then passes down is the one the lookup already created. Both approaches live side by side in Evenhall: helpers that look the client up where they need it, and a loader that resolves it once and passes it down. Passing it down makes the sharing visible in the code; looking it up relies on the registry. Inside a request scope, both end with one client per URL.

## The key decides which calls share a client

Isolating requests was the easy half. The OAuth and support helpers had a contract that the first client wins: every call within a request must get the same client, whichever URL it resolves to. They now hold their client under a constant key inside the same per-request map:

*Adapted for publication.*

```ts
const OAUTH_CLIENT_KEY = &quot;oauth-client&quot;;
const clients = new Map();

function getOAuthClient(runtimeEnv?: Record): Client {
  const cache = requestScopeStore() ?? clients;
  const existing = cache.get(OAUTH_CLIENT_KEY);
  if (existing) return existing;
  // ... resolve the URL and token, create the client, cache.set(OAUTH_CLIENT_KEY, client) ...
}
```

Our first round of the change keyed these two by URL, as the main client is, and that broke the contract while request isolation still held. The difference is the order of the steps. The main `getClient` resolves a URL from its environment and then looks it up, so one request can hold several URL-keyed clients. The helpers look up their constant key first, so every later call in the same request returns the first client, even if its environment argument differs. The scope decides which registry a request uses; the key decides which calls inside it share a client.

Our integration tests use the key too. The two files we inspected on 15 October 2026 open a scope and insert a test client under the URL that `getClient` will resolve, so the ordinary lookup returns it before reaching `createClient`, and the test swaps the database without changing the caller.

## The regression the same evening

The first version constructed the `AsyncLocalStorage` when the module loaded, and that module was also reachable from the browser. An indirect import from a layout brought the database client module into the client bundle. For the browser, the bundler replaces `node:async_hooks` with a shim that has no `AsyncLocalStorage` class, and the pages that loaded the module threw at load with `AsyncLocalStorage is not a constructor`.

The import was older than the fix. As long as the module only created a `Map` at load, nothing in the browser noticed. One line that constructs a Node class at module scope turned an unnoticed import into a broken page, and we found it in a later lane run.

![An indirect import from a layout brings the database client module into the browser bundle. There the bundler’s shim for node:async_hooks has no AsyncLocalStorage. The first version constructed it at load and threw.](/images/blog/a-shared-database-client-across-worker-requests/import-chain.svg#wide,phone,fit &quot;How a server module reached the browser, and why constructing AsyncLocalStorage at load broke the pages that loaded it.&quot;)

Source: TEEI, drawn from our platform code on 16 October 2026.

We fixed it the same evening by building the storage on first use, and only when the class exists. This is the code in Evenhall today:

*Verbatim excerpt.*

```ts
const clients = new Map();
let scope: AsyncLocalStorage&amp;gt; | null | undefined;

function requestScopeOrNull(): AsyncLocalStorage&amp;gt; | null {
  if (scope !== undefined) return scope;
  scope =
    typeof AsyncLocalStorage === &quot;function&quot; ? new AsyncLocalStorage() : null;
  return scope;
}

export function requestScopeStore(): Map | undefined {
  return requestScopeOrNull()?.getStore();
}

export function runWithRequestDbScope(fn: () =&amp;gt; Promise): Promise {
  const requestScope = requestScopeOrNull();
  return requestScope ? requestScope.run(new Map(), fn) : fn();
}
```

On the server nothing changes. In the browser, `requestScopeStore()` answers `undefined` and `runWithRequestDbScope` simply calls the function; `getClient` and the two helpers read the same accessor. The guard makes the module safe to load in the browser. Finding imports like this one is the subject of a separate note on what belongs in a React Router client bundle.

## The tests that guard it

We guard this behaviour with four tests in two files, which ran green on 15 October 2026. The first file checks that one scope gets the same client back across an `await`, that overlapping scopes asking for the same URL get different clients, and that the module map still answers outside a scope. The middle test carries the fix:

*Adapted for publication.*

```ts
import { expect, it } from &quot;vitest&quot;;
import { getClient, runWithRequestDbScope } from &quot;./db&quot;;

const runtimeEnv = { DATABASE_URL: &quot;&quot;, DATABASE_TOKEN: &quot;&quot; };

it(&quot;isolates the same URL between overlapping scopes&quot;, async () =&amp;gt; {
  const [first, second] = await Promise.all([
    runWithRequestDbScope(async () =&amp;gt; {
      const client = getClient(runtimeEnv);
      await Promise.resolve();
      expect(getClient(runtimeEnv)).toBe(client);
      return client;
    }),
    runWithRequestDbScope(async () =&amp;gt; {
      const client = getClient(runtimeEnv);
      await Promise.resolve();
      expect(getClient(runtimeEnv)).toBe(client);
      return client;
    }),
  ]);
  expect(first).not.toBe(second);
});
```

The test contacts no database; it only compares objects. The `await Promise.resolve()` makes both callbacks yield, so each must still find its own client after the other has run in between.

The second test mocks `node:async_hooks` the way the bundler hands it to the browser, then imports the module:

*Adapted for publication.*

```ts
import { expect, it, vi } from &quot;vitest&quot;;

vi.mock(&quot;node:async_hooks&quot;, () =&amp;gt; ({ AsyncLocalStorage: undefined }));

it(&quot;uses clients when the async-hooks shim has no AsyncLocalStorage&quot;, async () =&amp;gt; {
  const { getClient, runWithRequestDbScope } = await import(&quot;./db&quot;);
  expect(() =&amp;gt; getClient(runtimeEnv)).not.toThrow();
  await expect(
    runWithRequestDbScope(async () =&amp;gt; getClient(runtimeEnv)),
  ).resolves.toBeDefined();
});
```

On 15 October 2026 we checked both, in the reproduction under &quot;Verify this&quot;, with deliberate regressions. When `runWithRequestDbScope` calls the function directly and `getClient` reads only the module map, the isolation test fails because both scopes get the same client. When we construct the storage at module scope, the browser test fails with the same `AsyncLocalStorage is not a constructor` the browser showed. The tests run in Node, so they do not reproduce request cancellation in the Workers runtime or build a browser bundle, and they do not check the helpers&apos; first-client contract, several URLs in one scope, or nested scopes.

## Test the identity contract separately from the runtime failure

The registry has two independent contracts: requests must not share clients, and calls that should share a client inside one request must keep doing so. Testing only the first would have missed our helper regression. Extend the reproduction with these cases before applying the pattern to another resolver:

Table: Expected client identities in the request-scoped design. This is a test specification derived from the lookup rules, not a new Workers measurement.

| Calls under test | Expected identity | Why |
|---|---|---|
| Same URL twice in one scope, with an await between | Same object | The scope keeps its registry across asynchronous work |
| Same URL in overlapping scopes | Different objects | Each request owns a separate registry |
| Two URLs in one scope | Different objects | The main resolver keys by URL |
| Inner scope, then a lookup after it returns | Outer client restored | A nested run has its own map and must not replace the outer one |
| Helper called twice with different environments in one scope | First helper client | Its constant key is looked up before resolving the environment |

Use object identity for these assertions, with a client factory that sends no query. Then run a separate Workers-runtime case if you need evidence about cancellation or cross-request I/O. A green identity test establishes the registry rule; it does not establish that every application entrypoint opened a scope or that a particular promise chain caused the original hang.

## When a new client per call is the better choice

On the shared platform for TEEI Language and TEEI Mentorship, we met the same class of failure and chose differently: we create a new client on each call and cache nothing. Reduced to its core, and simplified from the code as it stands:

*Adapted for publication.*

```ts
export function getClient(runtimeEnv): Client {
  const url = readEnv(runtimeEnv, &quot;DATABASE_URL&quot;);
  const authToken = readEnv(runtimeEnv, &quot;DATABASE_TOKEN&quot;);
  if (!url || !authToken) throw new Error(&quot;Missing database configuration&quot;);
  return createClient({ url, authToken }); // new instance per call
}
```

For the `http:` and `https:` URLs we use, creating a client sends no request until a query runs. With a `ws:` or `wss:` URL, the same `@libsql/client/web` entry point opens a WebSocket as soon as the client is created. In [its Hyperdrive troubleshooting guide](https://developers.cloudflare.com/hyperdrive/observability/troubleshooting/), Cloudflare recommends: &quot;Create a new database client on every request instead of caching it in a global variable.&quot;

If no caller depends on getting the same client twice within a request, and no test injects a client, a new client per call removes the registry and its scope boundary, and gives every call its own limiter. In Evenhall, we keep the registry because our helpers and tests rely on that identity. We have not measured the cost difference between the two.

The language platform still keeps something per request, but it is a queue of reads, not a client. The code above leaves it out: inside the scope our Worker opens around each HTTP request, `getClient` wraps the new client so that the reads it recognises in `execute` wait in an `AsyncLocalStorage` store until a zero-delay timer fires, then go out together in read batches of bounded size, or alone when only one is waiting. A write, a transaction or an explicit batch sends the waiting reads first. Outside a scope, `getClient` returns the client unchanged; apart from HTTP requests, one credential job opens a scope of its own for its batches of reads. What that did to the number of round trips is in a separate note on why our Workers run near the database rather than near the user.

## Verify this

You can reproduce the isolation and the browser guard without a database and without a Worker. Make `db.ts` from the code in &quot;The regression the same evening&quot;, with the two imports from the first version at the top, and add this `getClient`:

*Adapted for publication.*

```ts
export function getClient(env: { DATABASE_URL: string; DATABASE_TOKEN: string }): Client {
  const url = env.DATABASE_URL;
  const cache = requestScopeStore() ?? clients;
  const existing = cache.get(url);
  if (existing) return existing;
  const client = createClient({ url, authToken: env.DATABASE_TOKEN });
  cache.set(url, client);
  return client;
}
```

Save the tests beside it as `iso.test.ts` and `browser.test.ts`, in separate files because the mock applies to a whole file. In each, use `const runtimeEnv = { DATABASE_URL: &quot;http://localhost:1&quot;, DATABASE_TOKEN: &quot;x&quot; };` (in the browser test, below the `vi.mock` line); these are dummy values, and the tests send no query. Then run `npx vitest run`. We ran them on 15 October 2026 at 14:34 UTC with Vitest 5.0.0 and `@libsql/client` 0.18.0: two tests, both passed.

The runtime warning and the cancelled requests need the Workers runtime, and we have not run a minimal version of that. Our before and after came from an internal test lane that readers cannot inspect.

## Appendix: configuration

As read from Evenhall&apos;s configuration and the installed packages on 15 October 2026.

- Compatibility date `2026-04-01`, with the `nodejs_compat` flag, which provides `AsyncLocalStorage` ([Node.js compatibility in Workers](https://developers.cloudflare.com/workers/runtime-apis/nodejs/)). `handle_cross_request_promise_resolution` is on by default from 2024-10-14, and we do not set its opposite.
- `@libsql/client` 0.18.0, imported as `@libsql/client/web`. The HTTP client&apos;s concurrency limit is left at its default of 20.
- `react-router` 8 and `wrangler` 4, as declared in Evenhall&apos;s `package.json`.</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Cloudflare Workers</category><category>AsyncLocalStorage</category><category>libSQL</category><category>Evenhall</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>One Cron Owner, Still Duplicate Ticks</title><link>https://theeducationalequalityinstitute.org/blog/coordinating-scheduled-jobs-across-workers-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/coordinating-scheduled-jobs-across-workers-en/</guid><description>We schedule two products from one Worker. We guard individual jobs against duplicate effects and check their outcomes separately from tick delivery.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>We schedule two products from one Cloudflare Worker. In October 2026 we recorded four periods in which our ticks arrived twice, and we have not established the cause. We give the schedule one owner, guard effects inside individual jobs and check their outcomes separately from tick delivery. On 28 June we recorded a fresh heartbeat for the scheduler while one of its jobs had never run. If you deploy the same code to more than one Worker, you need to decide who owns the schedule, how you prevent duplicate effects and how you find jobs that never start.

[TEEI Language](/programmes/education/teei-language/) and [TEEI Mentorship](/programmes/education/teei-mentorship/) are two products on one platform, built from one React Router 8 codebase and deployed as two Workers; [How TEEI runs its platforms on Cloudflare Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/) describes the account as a whole. Behind them runs scheduled work: meeting rooms created ahead of a session, reminders, expiry of unanswered requests, digests, health probes, and since 13 October the scheduled jobs of Skills Academy.

**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.

## One owner for the schedule

A [Cron Trigger](https://developers.cloudflare.com/workers/configuration/cron-triggers/) calls the Worker&apos;s [`scheduled()` handler](https://developers.cloudflare.com/workers/runtime-apis/handlers/scheduled/) with `controller.cron`, the expression that fired. Several triggers on one Worker all call the same handler, and the value of `controller.cron` &quot;must match character-for-character, including spacing&quot; the string in your configuration. [Cloudflare&apos;s own example](https://developers.cloudflare.com/workers/examples/multiple-cron-triggers/) switches on it, and so do we.

The Language Worker declares three expressions and selects a job set by the one that fired. The five-minute expression runs the jobs that cannot wait a quarter of an hour. The quarter-hour expression three minutes past the quarter runs the heaviest job in an invocation of its own, and since 11 October also four read-only probes. Everything else is the maintenance tick.

*Adapted for publication.*

```jsonc
&quot;triggers&quot;: {
  // */15    = the maintenance set (attendance, expiry, recurrence, win-back, health).
  // 1-59/5  = meetings created ahead of time, reminders, push retries and Skills room
  //           clean-up. Reminders ride this tick ONLY: adding them back to */15
  //           duplicates provider work and creates races on the same rows.
  // 3-59/15 = lifecycle deliveries, isolated in its own invocation so the
  //           heaviest job gets a full budget.
  &quot;crons&quot;: [&quot;*/15 * * * *&quot;, &quot;1-59/5 * * * *&quot;, &quot;3-59/15 * * * *&quot;]
},
```

We deploy the same code to Mentorship with a separate configuration and an explicitly empty cron list.

*Verbatim excerpt.*

```jsonc
  // Mentorship is web-only. Language remains the sole scheduled owner.
  &quot;triggers&quot;: {
    &quot;crons&quot;: []
  },
```

Some scheduled work still belongs to Mentorship&apos;s data. Skills Academy runs on the Mentorship Worker, and since 13 October its scheduled jobs run there too. We schedule them from Language&apos;s trigger and call Mentorship through a [service binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/), which has no public URL ([What We Bypass with Cloudflare Service Bindings](/blog/worker-to-worker-communication-service-bindings-rpc-en/) covers the binding). There is no local fallback: running the job inside Language would claim Mentorship&apos;s rows under Language&apos;s feature flags, so a failed call goes to the logs and to Sentry. We record a completed run only when Mentorship reports a job outcome; a response without one is a dispatch failure, and a job that ran and degraded is a completed run with a degraded result.

![Diagram: TEEI Language declares three cron expressions and TEEI Mentorship none. Language calls Skills Academy jobs on the Mentorship Worker through a service binding; a call without a reported outcome is a dispatch failure, and a failed call is not run locally but goes to the logs and Sentry.](/images/blog/coordinating-scheduled-jobs-across-workers/one-owner.svg#wide,phone,fit &quot;We schedule both products from one Worker. Since 13 October 2026, Skills Academy’s scheduled jobs run beside their data in Mentorship.&quot;)

Source: TEEI, drawn from our platform code on 16 October 2026.

Ownership has to survive configuration changes, so we guard it in three places.

- **One owner, tested.** A test reads the expressions from both Workers&apos; configuration files rather than from a hand-typed copy, because a hand-typed copy would let a fourth expression added to the Language configuration run the whole maintenance set a second time with every test green. As of 15 October 2026, it requires the three expressions in Language production and staging, pins the five declared Mentorship cron arrays to empty lists, and requires every other declared Language cron array to be empty.
- **An opt-in per Worker.** We require a per-Worker opt-in for reminders, no-show handling and the main maintenance set. We added the switch after a production deployment on 26 June left the scheduler armed against demonstration data.
- **An explicit empty list in every other environment.** On 3 August a preview environment that declared no triggers of its own came out with production&apos;s three expressions; our upload guard refused that first build, which is the only reason we noticed. We observed this inheritance in our build; we cannot quote it from the [environment documentation](https://developers.cloudflare.com/workers/wrangler/environments/). Cloudflare does document that an empty `crons` list removes every trigger, and that &quot;commenting out the `crons` key will not disable a Cron Trigger&quot;. So every environment that should not run the scheduler declares `&quot;crons&quot;: []`.

## Inside one tick

Until August, every job started in its own `waitUntil` the moment the tick fired, 22 of them within three seconds, all against one database primary. On 12 August we observed database reads for people using the platform timing out during these bursts, and we changed the dispatcher to a bounded queue; the [platform note](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/) gives the measurement.

Before the queue, a deployment without database access refuses to run the scheduler. We then start a Sentry check-in for the expression that fired, a heartbeat for the handler itself and a ping to an external dead-man&apos;s switch outside Cloudflare, and none of them blocks dispatch: they can remain in flight while the jobs run.

![Sequence diagram of one TEEI Language cron tick. We start the check-in send, handler heartbeat and external ping before draining the queued jobs; these operations can remain in flight during dispatch. As of 15 October 2026, we drain up to four queued tasks at a time, or two on the lifecycle tick; for local route tasks that return a response, we await an outcome-write attempt before releasing the lane.](/images/blog/coordinating-scheduled-jobs-across-workers/one-tick.svg#wide,phone,fit &quot;We start the check-in and heartbeat before draining the queue. As of 15 October 2026, we drain up to four jobs at a time, or up to two on the lifecycle tick.&quot;)

Source: TEEI, drawn from our platform code on 16 October 2026.

*Adapted for publication.*

```ts
const drainCronQueue = async (concurrency = CRON_DISPATCH_CONCURRENCY): Promise =&amp;gt; {
  const queued = cronQueue.splice(0, cronQueue.length);
  let next = 0;
  const workers = Array.from(
    { length: Math.min(concurrency, queued.length) },
    async () =&amp;gt; {
      while (next &amp;lt; queued.length) {
        const task = queued[next++]!;
        await task();
      }
    },
  );
  await Promise.all(workers);
};
```

Each invocation drains a snapshot of its own queue through a fixed number of lanes: as of 15 October 2026, up to four, and up to two on the lifecycle tick. The lanes share one cursor and advance it before awaiting a task, and the job wrapper catches failures, so one failing job does not stop its lane. Two concurrent invocations have separate queues, so we limit concurrency within an invocation and rely on per-job guards when invocations overlap. A test goes red if the fan-out comes back, and every branch that queues work registers its drain under one `waitUntil`; a test for the video-deletion job once found a queue that was filled on one path and never drained.

Most queued tasks are local route jobs. We call the React Router request handler in process for them, using the same feature-flag overlay as page requests. For each call we create a fresh `RouterContextProvider` and populate it with the flagged environment, execution context and logger, the same typed context contract the web loaders use. One job is queued as a direct function call instead, and the Skills Academy jobs go to the other Worker.

Around each local route job, three choices are deliberate. Completion and outcome are separate: a job that answered 500 did finish, so its &quot;last completed&quot; marker stays fresh while we record its outcome as degraded. We await a bounded outcome-write attempt inside the drain&apos;s existing `waitUntil`, because on 2 July a heartbeat write that we registered under a second, nested `waitUntil` from the last of several concurrent jobs was lost on every tick, and that job looked as if it had never run. If the write still fails after its retries, we log the completion as unrecorded rather than as a normal completion. And the wrapper never throws into `scheduled()`: we log a failing job, send it to Sentry, and the rest of the tick carries on.

## A repeated tick needs a guard in the job

A single owner does not prevent a repeated delivery. Our ticks arrived twice in four periods in October 2026: on 2, 3 and 4 October and from 7 to 8 October. We have not established the cause. We guard each job&apos;s logical effect, such as a booking, recipient or reporting period; the raw `scheduledTime` alone does not identify one. Here is the smallest of them, in the job that expires requests a volunteer never answered:

*Adapted for publication.*

```ts
it(&apos;two runs in flight expire one stale request exactly once&apos;, async () =&amp;gt; {
  const id = await seedRequested({ expiresAtMsFromNow: -60_000, slotId: &apos;slotTwice&apos; });

  const [first, second] = await Promise.all([expireStaleRequests(ENV), expireStaleRequests(ENV)]);

  expect(first.expired + second.expired).toBe(1);
  const stored = await db.execute({ sql: &apos;SELECT status FROM bookings WHERE id = ?&apos;, args: [id] });
  expect(stored.rows).toEqual([expect.objectContaining({ status: &apos;declined&apos; })]);
  // … one slot released, one set of notices
});
```

The test runs against a database built from the real migration chain, not a hand-written copy of the schema. The guard starts with one condition on one `UPDATE`:

*Adapted for publication.*

```ts
// The booking write is guarded on status = &apos;requested&apos; so a concurrent accept/decline
// that already moved the row wins (rowsAffected 0 -&amp;gt; skip).
{
  sql: `UPDATE bookings
           SET status = &apos;declined&apos;, terminal_reason = ?, updated_at = ?
         WHERE id = ? AND status = &apos;requested&apos; AND request_initiator = ?`,
  args: [terminalReason, now, bookingId, requestInitiator],
},
// … after the batch commits:
if (Number(results[transitionIndex]?.rowsAffected ?? 0) !== 1) {
  result.skipped += 1;
  continue;
}
```

The status condition gives one run the transition; in the same batch, the slot is released only while still held for this booking, and notice rows carry a unique key. After the commit, the losing run sees zero rows touched and skips its reporting.

As of 15 October 2026, sixteen test files each run a job twice at the same moment with `Promise.all` and require the effect once, and we showed each one failing with its guard removed. A passing concurrent run alone would not show that the guard made the difference; removing the guard must break the same persisted-effect assertions.

We test three boundaries separately. The configuration test reads declared arrays. A dispatcher proof runs the real `scheduled()` with the expressions read from configuration, but replaces the router and heartbeat writers, so it proves which jobs were selected, not that a row was written; in a negative control on 12 September 2026, adding a fourth expression, `*/7`, made the job sets overlap and turned it red with 26 collisions. The concurrent job tests inspect persisted effects. None of them proves which triggers Cloudflare actually fires. For that boundary we read the fired expression in Workers Logs, and that is where our next lesson came from.

## Changing an expression is not one deploy

On 5 October we moved the five-minute expression from `*/5` to `1-59/5`, one minute off the five-minute grid.

What the move taught us is about deploys. In our release lane, we upload a version and then deploy it (`wrangler versions upload`, then `wrangler versions deploy`), and neither command changes the triggers. A plain `wrangler deploy` would replace them with the configuration&apos;s list; our lane changes them separately with `wrangler triggers deploy`. So the new code went live while production still fired the old expression. Because the handler compares `controller.cron` character for character, every old `*/5` tick would have matched neither named expression and run the whole maintenance set, 30 jobs instead of 4, while the meeting rooms and the reminders never ran. A test with a negative control now proves that. The handler accepts both expressions for one release:

*Verbatim excerpt.*

```ts
export const PRECREATE_CRON_PREVIOUS = &apos;*/5 * * * *&apos;;

/** Whether a fired cron expression is the 5-minute tick, under its new or its previous name. */
export function isPrecreateCron(cron: string): boolean {
  return cron === PRECREATE_CRON || cron === PRECREATE_CRON_PREVIOUS;
}
```

Cloudflare documents that changes to triggers &quot;may take several minutes (up to 15 minutes) to propagate&quot;. We measured longer. On 6 October, Cloudflare&apos;s schedules API returned the new expression for the production Worker from 07:56 UTC, and the old expression kept firing for another 44 minutes. On staging on 5 October, the first `1-59/5` invocation came more than four hours after the deploy. The schedule the API reports is not the schedule that fires. So our rule for removing the alias is the fired expression: Workers Logs must show `$workers.event.cron` as `1-59/5` and no `*/5` tick for a full day, on production and on staging.

## When a tick runs out of time

A scheduled invocation has at most 15 minutes of wall time, by Cloudflare&apos;s [limits](https://developers.cloudflare.com/workers/platform/limits/). On 24 September two quarter-hour ticks in a row reached it and were cut off at 900 seconds, and every job still waiting in the queue behind them was lost. Three fan-out jobs used serial recipient loops. In measurements on 28 and 29 September 2026, the &quot;available now&quot; fan-out took over nine minutes on its own.

On 29 September we replaced the serial loops with a bounded sweep: four recipients in flight. The dispatcher bounds the jobs in an invocation; the sweep bounds the recipients in a job. Two of the notice jobs also stop starting work after four minutes and leave unstarted items for the next tick. Tests pin both stops: after a failed lane or a spent budget nothing new starts, and work already in flight settles before a failure is rethrown. In an adaptively sampled read of Workers Logs for the seven days to 15 October 2026, the longest maintenance tick in the sample used about a fifth of the wall-time ceiling.

None of this saves the dispatcher&apos;s own queue, which lives in memory and is lost if the invocation is cut off. Resuming safely depends on each job keeping its unstarted work durably.

## Knowing when a job did not run

A job that throws is easy to see; a job that never starts leaves nothing behind. On 28 June the handler&apos;s heartbeat was fresh while one job had never run. Since then we keep a heartbeat for each job in a monitored register, with markers for the last run, the last success and a degraded outcome, and a self-check on the maintenance tick warns in Sentry when a job that should run has gone stale. Around it sit three wider checks: an external dead-man&apos;s switch outside Cloudflare, which still works if the whole account is what stopped; a monitor Worker that reads the health of both programmes every five minutes and escalates; and a [Sentry cron monitor](https://docs.sentry.io/platforms/javascript/guides/cloudflare/crons/) per expression, which says that the tick started and nothing more.

![Diagram of four alert layers for scheduled jobs, from outside in: an external dead-man’s switch, a monitor Worker every five minutes, a Sentry cron monitor per expression, and a heartbeat for each job in the monitored register with a self-check.](/images/blog/coordinating-scheduled-jobs-across-workers/alert-layers.svg#wide,phone,fit &quot;As of 15 October 2026, we use Sentry check-ins to record that a tick started and per-job heartbeats to record completion and outcome for monitored jobs.&quot;)

Source: TEEI, drawn from our platform code on 16 October 2026.

That last check once raised a false alarm. On 27 September Sentry declared the 11:00 UTC maintenance tick missed, while Workers Logs show that it ran for 583,504 milliseconds, under the 900-second ceiling, and every job had run. We called `captureCheckIn` at the start of the tick, but in `@sentry/cloudflare` that only queues the check-in; the transport sends what is queued when the client is flushed, after every `waitUntil` of the invocation has settled. So the check-in left when the tick ended. We now start sending it straight away:

*Adapted for publication.*

```ts
try {
  captureCheckIn(
    { monitorSlug: monitor.slug, status: &apos;ok&apos; },
    {
      // The expression that actually fired, so the monitor expects the trigger Cloudflare runs.
      schedule: { type: &apos;crontab&apos;, value: cron },
      // … margin, thresholds and time zone
    },
  );
  const transport = client.getTransport();
  if (!transport) return undefined;
  const sent = Promise.resolve(transport.flush(CHECK_IN_SEND_TIMEOUT_MS)).then(
    (drained) =&amp;gt; ({ drained: drained === true }),
    () =&amp;gt; ({ drained: false }),
  );
  ctx.waitUntil(sent);
  return sent;
} catch {
  // Observability cannot stop a scheduler tick.
  return undefined;
}
```

We flush only what is already queued; the tick&apos;s later events still leave at the end. A real-SDK regression test holds a scheduled job open and requires one check-in before that job settles, and still exactly one after the final flush. A second test makes `fetch` reject and requires every promise registered under `waitUntil` to settle anyway, because a failure to reach Sentry must not reject the scheduler tick. Until 27 September, check-in lateness mixed scheduler start delay with execution time, so we do not read our older check-in data as start delay. [How We Join React Router Errors Across Browser and Worker](/blog/tracing-a-failure-from-react-router-to-workers-with-sentry-en/) covers the rest of our Sentry setup.

In [Evenhall](/partners/evenhall/), partner companies report what their employees did through TEEI&apos;s programmes. Its daily jobs run from a small scheduler Worker that, unlike Language&apos;s, rethrows a failed dispatch so that it shows in Cloudflare&apos;s invocation status. Evenhall&apos;s daily sync went stale on 12 July, and for two months the scheduled jobs did not reach the Evenhall Worker. The schedule was intact and the scheduler did fire; in the words of our runbook, &quot;it fails on the outbound call&quot;. In a six-day window we measured on 6 September, 27 of 29 invocations ended in `scriptThrewException`, and the Evenhall Worker received none of their requests. That window is a sample and does not show every tick of the two months. The scheduler we had deployed had no service binding, and we repaired delivery with production deploys on 17 September.

The silence had a cause of its own. When we wrote the alert row for a job that had never run, we also gave it a &quot;last run&quot; of that moment, so the job reported healthy for ever. We fixed that on 6 September, the same afternoon we split reading the health of the jobs from writing alerts about it, and alerting for stale jobs has been on in production since 28 September. The comment on that switch says it plainly: &quot;nobody was told for two months&quot;. Two things were wrong at once, the binding and the silence, and we have not measured them apart.

A test now starts with no recorded runs, runs the alerting step and requires the run history to stay empty and the job to remain unknown. We evaluate health from recorded rows and a clock, without a database client, so reading health cannot manufacture evidence that a job ran.

## When this pattern is the wrong choice

- **One owner for two products.** The Language Worker&apos;s ticks carry both products&apos; jobs under one 15-minute ceiling, and Mentorship cannot be scheduled on its own. If your two Workers have different owners or different release cadences, give each its own schedule and its own data.
- **Guards per job.** Overlap protection lives in each job, not in the dispatcher, so every new job needs its own guard written and proven. If a job&apos;s effect has no key, a lock can limit overlap and a queue can control delivery, but neither replaces an effect identity and a documented retry and recovery rule. [Why We Freeze Email Payloads on Cloudflare Queues](/blog/reliable-email-cloudflare-queues-transactional-outbox-en/) and [Durable Object alarms: stale decisions, moving deadlines](/blog/durable-object-alarms-concurrent-updates-expiring-state-en/) describe the two approaches we use elsewhere.
- **Throwing or not.** Language&apos;s dispatcher catches a job&apos;s failure, records it and alerts, so a green Cloudflare status does not show that the tick&apos;s jobs succeeded. Evenhall&apos;s scheduler throws, so the status is a signal, but one failing call ends the invocation. Choose by which signal you will actually watch.
- We have not measured what it would cost or save to spread this work over more Workers. There is no comparison to report.

## Verify this

This is a minimal example of our own, not code from our platform. It reproduces two invocations of the same job at the same moment, not the platform&apos;s own double delivery, and it shows where duplicate suppression ends.

1. Create a [D1](https://developers.cloudflare.com/d1/) database, bind it to a Worker, and create two tables. The unique key in `claims` identifies one effect for one job; `effects` records what happened:

   *Adapted for publication.*

   ```sql
   CREATE TABLE claims (job TEXT NOT NULL, effect_key TEXT NOT NULL, UNIQUE (job, effect_key));
   CREATE TABLE effects (job TEXT NOT NULL, effect_key TEXT NOT NULL, at INTEGER NOT NULL);
   ```

2. In `scheduled()`, key the claim on the effect, here one digest per UTC day, not on the delivery. Claim first, and perform the effect only if the claim inserted a row.

   This example demonstrates duplicate suppression, not crash-safe execution. A process that stops after claiming and before performing the work can leave the work undone; the failure case in step 5 demonstrates that boundary.

   *Adapted for publication.*

   ```ts
   export default {
     async scheduled(controller, env) {
       const key = new Date(controller.scheduledTime).toISOString().slice(0, 10);
       const claim = await env.DB
         .prepare(&apos;INSERT OR IGNORE INTO claims (job, effect_key) VALUES (?, ?)&apos;)
         .bind(&apos;digest&apos;, key)
         .run();
       if (claim.meta.changes !== 1) return;
       await env.DB
         .prepare(&apos;INSERT INTO effects (job, effect_key, at) VALUES (?, ?, ?)&apos;)
         .bind(&apos;digest&apos;, key, Date.now())
         .run();
     },
   };
   ```

3. Apply the schema to the local database and start with both tables empty. Start the Worker with [`wrangler dev --test-scheduled`](https://developers.cloudflare.com/workers/examples/cron-trigger/) and send two requests, within the same UTC date, to its local scheduled route at the same moment:

   *Adapted for publication.*

   ```sh
   PORT=8787
   URL=&quot;http://localhost:$PORT/cdn-cgi/local/scheduled?cron=*/5+*+*+*+*&quot;
   curl -s &quot;$URL&quot; &amp;amp; curl -s &quot;$URL&quot; &amp;amp; wait
   ```

4. Count the effects with `wrangler d1 execute  --local --command &apos;SELECT COUNT(*) FROM effects&apos;`. Expected: 1. Then clear both tables, remove the claim and its check, and send the two requests again. Expected: 2.
5. Restore the claim and its check, clear both tables and add a deliberate throw immediately after a successful claim. Invoke once, remove the throw, then invoke again within the same UTC day without clearing either table. Expected: one claim and zero effects. The claim and the effect are separate writes, so the claim that suppresses a duplicate also suppresses the retry; resuming after an interruption needs a rule of its own.

## Appendix: versions

As of 15 October 2026, TEEI Language and TEEI Mentorship run on React Router 8 and Wrangler 4, and the website on Astro 7.

If you are building with Cloudflare or with TEEI, [talk to us](/partners/technology/) and we will walk you through the code.</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Cloudflare Workers</category><category>Cron Triggers</category><category>Idempotency</category><category>Sentry</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>How We Compare Builds and Verify a Worker Release</title><link>https://theeducationalequalityinstitute.org/blog/deploying-the-exact-worker-you-tested-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/deploying-the-exact-worker-you-tested-en/</guid><description>We once used one measurement for both builds. We now take separate readings, verify the serving Worker version and assess rollback against the live schema.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>On 12 September 2026 we corrected a check that reused one measurement for two build manifests, so it compared every invariant with itself and could never reject a difference in build inputs. Our build-input comparison now uses separate readings and compares those inputs against a declared list of permitted differences. We also read back the version serving traffic on the named Worker. For our Astro website, we remove development-only files before recording the candidate&apos;s digest, and promote only a matching candidate. Before rolling back TEEI Language or TEEI Mentorship, we assess whether the older code can work against the live database schema.

A check can pass without comparing anything. The remedy throughout this note is to read the other side of each comparison on its own: from the build, the Worker or the database. TEEI Language and TEEI Mentorship are two Workers built from [one codebase](/blog/one-codebase-two-programmes-react-router-8-cloudflare-workers-en/); Evenhall is where partner companies&apos; managers work. The note belongs to our series on [how TEEI runs its platforms on Cloudflare Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/). Its three release chains prove different things, which do not add up to one guarantee.

**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 a deploy does and does not tell you

A zero exit code from `wrangler deploy` tells you that an upload succeeded somewhere, not that the Worker answering your users is the build you tested. In the words of the [versions and deployments](https://developers.cloudflare.com/workers/versions-and-deployments/) documentation: &quot;Every time you change your Worker&apos;s code or configuration, Workers creates a **version**. A **deployment** determines which version(s) are actively serving traffic.&quot; With [`wrangler versions upload`](https://developers.cloudflare.com/workers/wrangler/commands/workers/), we create a version without sending traffic to it; with `wrangler versions deploy`, we activate the version we name by its ID.

A version is code and configuration. The [rollback documentation](https://developers.cloudflare.com/workers/versions-and-deployments/rollbacks/) is plain: &quot;Resources connected to your Worker will not be changed during a rollback.&quot; Our database is outside Cloudflare altogether, so no rollback ever touches it.

Across our three release chains, five records answer five different questions, and none of them stands in for another.

Table: What each release record identifies, and what it does not show. Source: the release chains described in this note, as read on 15 October 2026.

| Record | What it identifies | What it does not show |
|---|---|---|
| Staging result | A check that passed against the build deployed to staging | That production runs the same bytes; for TEEI Language and TEEI Mentorship, production is a separate build of the same commit |
| Candidate digest | The exact files of one build candidate, on the website | That the staging gate observed those files, or that any Worker serves them |
| Repository commit | The source revision a build says it came from | The bytes that were built; uncommitted files and build-time choices can differ |
| Uploaded version ID | The code, configuration, bindings and secrets Cloudflare stored for one upload | That any traffic reaches it; an upload moves none |
| Traffic allocation | Which versions the named Worker serves, and at what share | Which build a version contains; that link comes from our own upload record or, on Evenhall, from identity written into the version |

## TEEI Language and TEEI Mentorship: two builds of one commit

![Diagram of the TEEI Language and TEEI Mentorship release. From one committed tree, after a clean-tree guard and with the environment chosen at build time, we make a staging build and a production build, connected by a comparison of measured build inputs against the declared differences. In the staging track, the artefact and configuration deployed to staging must have the same sha256 as the local staging build. In the two-step production track, we upload the production build as a version, approve it, activate it by version ID on the named Worker and read back which version is serving: our version, a failed verification, or a check that could not complete.](/images/blog/deploying-the-exact-worker-you-tested/two-builds.svg#wide,phone,fit &quot;Two builds of one commit, compared input by input. Staging is checked against the staging build; in production, we activate the exact version we uploaded and read it back from the named Worker.&quot;)

Source: TEEI, drawn from our platform code, read on 15 October 2026.

### Nothing uncommitted rides along

On 8 July 2026 a production deploy included another session&apos;s uncommitted feature code. Nothing broke, but we had built from the files on disk, so the commit in our deploy log did not describe what had shipped. On 9 July we added a guard to both deploy commands: before anything is built, a filtered status check over the paths that can change the artefact must come back empty. With the same guard, we take a lock per target, so two sessions cannot deploy the same Worker at once.

### The environment is chosen when the code is built

We choose the environment before we build, through `CLOUDFLARE_ENV`. The Cloudflare Vite plugin writes a flattened Wrangler configuration with no named environments, so `wrangler deploy --env staging` cannot change the environment already built into it. In our build script, we reject unknown targets and environments. We learned why on 3 August 2026, when a preview was built as `production` and uploaded carrying the production bindings. Since 25 July each target has had its own output directory and build lock, so building Mentorship cannot overwrite a Language build that is about to ship.

### Two builds, compared input by input

Because the environment is part of the build, staging and production are two builds of one commit. We accept that because the `ENVIRONMENT` variable separates the production flavour, with unrestricted email sending and the production analytics project, from everything else, and our pre-release test journeys must never run against it. We declare which build inputs may differ and compare separate readings of the inputs we measure.

Table: The two builds of one commit for TEEI Language and TEEI Mentorship. Source: our declared build-flavour axis, last changed 12 September 2026.

| Must be identical | May differ, and only these |
|---|---|
| Build command | The Cloudflare environment |
| Digest of the build configuration | The list of secret names the build expects |
| sha256 of the lockfile | The list of declared variable names |
| Node version | The error-tracking release name |
| sha256 of the package manifest | The `ENVIRONMENT` variable |
| Digest of the source files | The product analytics key |
| Source commit and git tree | `workers_dev` and the Worker `name` |
| Commit of the release tooling | Nothing else |

In that comparison, we measure each build separately and read configuration values back from its generated configuration. We reject changed recorded invariants as source divergence, and an undeclared axis key even when both builds record the same value; a declared key that happens to agree is reported, not refused. We reject a changed npm installation record even when the committed lockfile agrees: a stray install can change the dependencies npm installed. We also reject two manifests recorded at the same instant, and any required input missing from either reading, even when both omit it. We compare inputs, not the bytes of the two bundles, because allowing a bundled JavaScript file to differ does not tell us which changes it contains.

The self-comparison from the opening was our first wiring of this check, which also took the permitted differences from flavour labels rather than from the builds. We found it by reading the code that called the comparison, not the comparison itself. In tests, a valid pair is now our positive control, and changed source or lockfile digests, or one required input omitted from both readings, must be refused.

Staging has a separate comparison: the artefact deployed to staging and its configuration must have the same sha256 as the local staging build, which proves staging is the build we made. The table compares the staging and production builds, and we keep the two apart.

### Upload, then activate the version we uploaded

Since 15 September 2026 we have also released Language and Mentorship in the two steps Cloudflare separates. We upload an immutable version from a frozen copy of one commit, and after a human approval we activate exactly that version by its ID, never &quot;the latest&quot;.

*Adapted for publication.*

```bash
# Step 1: upload. No traffic moves.
npx wrangler versions upload --config //wrangler.json
#   We take the new Version ID from Wrangler&apos;s output; that is the ID we activate.

# Step 2: activate exactly that version on the named production Worker.
npx wrangler versions deploy --version-id  --percentage 100 \
  --name  --config  --yes
```

We pin the generated build configuration for the upload and the committed root configuration for the activation, because activating a stored version also applies the logging and observability settings of the configuration passed to that command. Migrations run separately, under an approval of their own.

### Reading the version back from the named Worker

On 6 August 2026 an audit found that an upload could reach the other programme&apos;s Worker. A build that generates a Wrangler configuration also writes a redirect file, `.wrangler/deploy/config.json`, and when we omit an explicit configuration, [we let Wrangler resolve it](https://developers.cloudflare.com/workers/wrangler/configuration/) for `deploy`, `versions upload` and `versions deploy`. Building both programmes in one checkout, we used whichever build had run last. Our Language probe checks a URL without naming a version, so a misdirected upload would have been probed on the untouched, healthy Language Worker, and passed.

The fix on 7 August has two parts. We pinned the configuration explicitly at five release call sites, which also gets us Wrangler&apos;s confirmation that it did not follow a redirect. In tests, we reject a wrong Worker name inside the right configuration file. Then we read the deployments of the Worker we named and compare the version serving traffic with the one we uploaded, whose ID we take from Wrangler&apos;s own output; a later call would only report whatever is active by then. In a traffic split, a version at zero percent does not pass. The check has three outcomes: the named Worker is serving our version, verification failed, or the check could not be completed. The third is not evidence against the release, and we never roll back on it. Before we activate, we capture the version the release replaces on that same Worker; it is the one a rollback would restore.

### A probe that could not reach the network

After a deploy, we confirm the version and probe the live service. When we probe Language, the answer is ok, failed or inconclusive. The third answer comes from 30 July 2026. That morning, during a Language deploy, we recorded `probe=FAILED` at 10:30 UTC. The operator&apos;s own network was down: requests to Language, Mentorship and cloudflare.com all failed in the same moment, and the automatic rollback could not run either. At 10:36 we recorded the new version as live on all traffic and healthy. Had the network come back a few seconds later, we would have reverted a healthy version. Now, when the Language probe fails, we check whether the network itself is reachable; if it is not, the answer is inconclusive, and we never roll back on it.

A probe is also a contract with production as it is today. The deploy logs we counted on 15 October 2026 hold two automatic rollbacks, one per programme, on 22 and 28 July. Both were triggered by probe problems: they demonstrate the rollback path, not confirmed application failures, and we corrected both probes. On 22 July 2026 the Language probe failed because a heartbeat key the new version had just added had never been seen; the scheduled jobs were healthy. On 28 July, after three failed Mentorship probes since 26 July, we restored the previous version, and the probe failed on that too: it still expected the closed service Mentorship had been before it opened on 24 July, and &quot;could not pass any code that was ever deployed&quot;, in the words of the commit that fixed it. We roll back automatically on a failed probe only in the release script for Language and Mentorship; for the website and Evenhall a person decides.

## The website: one build, promoted

For the website, we take the opposite approach. Its build is the same for both environments, so we promote the build that served staging instead of building again. We run the staging gate against the deployed staging site, and we compute the manifest digest over the local promotion candidate after removing the static files that exist only for development, so the gate result and the digest describe different observations in one release. We promote only when the gate passed and the candidate&apos;s digest matches the manifest.

The digest is a sha256 over the candidate&apos;s code and static assets: we sort the paths in a byte order independent of the machine&apos;s locale, hash the files in that order, then hash the ordered checksum records, which include the paths, so moving a file changes the digest. On 1 June 2026 a locale-dependent sort gave one identical build two different hashes.

Two fixes on 22 September 2026 changed what the website records. Until then, we read the commit for the manifest after the build, the deploy and the staging gate, in a working tree where several sessions commit all the time, so it often belonged to someone else&apos;s work. We now capture it once, when the deploy starts. In the words of that commit: &quot;The commit says roughly where the source was; the hash says exactly what was built.&quot;

The second fix was a rollback target that could never have worked. We had recorded it from `wrangler deployments list`, and the `id` we read there is a deployment ID, not a version ID; asked for it, the versions API answered 404. We now record the version from `wrangler deployments status --json`, which describes the deployment serving traffic. As a negative control, we ran the old capture against production and got a 404; the corrected capture recorded the active version, which the versions API could read. The website never rolls back by itself: if the production smoke test fails, we print the rollback command, and a person runs it.

## Evenhall: the identity written into the version

For Evenhall, we answer the same question a third way. In its promotion script, we require a person&apos;s staging verification for exactly this candidate, by commit and git tree, and stop if the source being built differs from the commit it claims to be. We pass the commit and tree as variables of the version we deploy, and since 13 September 2026 we ask the running Worker for them afterwards and compare them with what we meant to ship.

That describes the promotion script we inspected. It is not evidence that every recorded activation used that script or passed its staging gate.

On 6 September 2026 we found Evenhall querying a column production did not have. Production was 21 migrations behind the code, and the actions that need an audit write answered 503. Since that day, we stop at the same gate if the actual schema lacks a declared column the code needs without a fallback; the highest migration number cannot prove that column exists.

Evenhall&apos;s operations guide adds one more trap: when we run `wrangler secret put`, we create and deploy another version, so the rollback target is the version active at 100 percent immediately before the change, not the last code upload.

## Rollback moves code, not data

When we rehearsed `wrangler rollback` on a staging Worker on 2 July 2026, the command took seconds, which says nothing about whether the older code can work against the database as it is now. Since 12 September 2026, when we roll back Language or Mentorship, we first decide compatibility against the live schema, then roll back with the version and the Worker named explicitly, read the deployments back and probe. The target comes from an operator argument or the release journal, never from a line of the deploy log. We have not timed our current production rollback, which does more than the rehearsal.

![Compatibility decision for an identified Language or Mentorship candidate with a readable manifest. If the live migration ledger cannot be read, the verdict is RESTRICTED. If the required set of migrations is not satisfied, the verdict is UNSAFE. Otherwise every extra migration must be declared additive and carry none of the contract’s removal flags, and the candidate’s SQL writes, prepared against the current schema reconstructed from the migrations and bound to the live ledger, must meet no new refusal: no write rejected only by the current schema and no new or changed refusal trigger or higher count of CHECK clauses that applies to them. Both hold: SAFE for this observed schema. Either fails: RESTRICTED.](/images/blog/deploying-the-exact-worker-you-tested/rollback-decision.svg#wide,phone,fit &quot;A Workers rollback moves code, never data, so we decide against the schema that is live before any version moves.&quot;)

Source: TEEI, drawn from our platform code, read on 15 October 2026.

### What can be rolled back onto today&apos;s schema

For an identified candidate, the verdict is SAFE, RESTRICTED or UNSAFE; without an identified candidate and a readable manifest, we report that we could not determine compatibility. If we cannot read the live migration ledger, the verdict is RESTRICTED. If the ledger does not satisfy the candidate&apos;s required set of migrations, it is UNSAFE. An older migration head is expected for a rollback candidate, and we resolve aliases and historical equivalents first, so a renamed ledger entry does not become a fictitious new schema change. Every migration beyond the required set must then pass two layers; if either does not hold, the verdict is RESTRICTED. SAFE holds for one candidate and one observed schema, and only for what the contract examines: the required migrations, the excess migrations&apos; declarations and the candidate&apos;s non-interpolated writes against the reconstructed schema. It is not evidence about every data or application outcome. If the ledger moves, we decide again.

Layer A is the classification contract: every excess migration is declared additive, and a structural report over them carries none of the contract&apos;s removal flags, namely tokens removed from a redefined trigger, columns removed from a view or table, or a dropped object other than an index. Layer A alone is not enough, and we wrote down why on 11 September 2026: &quot;A structural diff showing only additive changes does NOT authorise moving the ground under an artifact nobody executed against the new schema: a new UNIQUE index, CHECK constraint, foreign key or trigger is structurally additive and can still refuse an old artifact&apos;s writes.&quot;

Layer B is the proof. We read a corpus of SQL write literals from the candidate&apos;s committed application libraries and rebuild the candidate&apos;s schema and the current schema in two scratch databases from the checked-in migrations. With `EXPLAIN`, we prepare each non-interpolated write against the reconstructed current schema. If preparation fails, we try the candidate&apos;s own schema as a control: a write both schemas reject is excluded, while a write only the current schema rejects is a compatibility failure. We then look for refusal guards that the current schema has and the candidate&apos;s does not: triggers new or changed for the matching write event, and a higher count of CHECK clauses in a table definition, which counts against inserts and updates on that table. We attribute those guards from the schema definitions; we do not observe them firing on requests with real arguments, so we count a relevant new guard against the candidate even when its condition might not fire. For production, we bind the report to the live migration ledger.

Rollback also restores the bindings and secrets a version was uploaded with. On 21 September 2026, `wrangler versions view` showed two secrets missing from older versions, one of them needed by a callback route that a rollback would have turned into a 503. To run older code with today&apos;s secrets, we upload that code again as a new version. Cloudflare also does not allow a rollback when the target version binds to an R2 bucket, KV namespace or queue that no longer exists.

The rollback decision once had the flaw from our opening. Until 12 September 2026, in the schema check for automatic rollback, we read a stale value from the deploy log, then fell back to a value derived from the constant we were checking against, so it passed by construction. We now decide against the live ledger, and we count a rollback as verified only when both the version check and the probe succeed. An explicit failure takes precedence over an inconclusive result; without a failure, an unavailable check leaves verification inconclusive.

### Two Workers, one database, and 14 October

Language and Mentorship share one Turso database, so a migration released with one programme changes the ground under the other. With a cross-programme gate, we decide, for the versions both programmes serve, whether the shared schema can advance independently, needs a coordinated release, or is unknown. We treat a missing or mismatched required migration as unknown. When a serving build predates the plan&apos;s last migration, we require a coordinated release even when the plan is labelled additive; that label describes risk, not permission. The one exception is a plan that creates only tables that did not exist, with their own indexes, triggers and inserts, a class we recompute from the SQL we are about to run and the live schema. A reference to an existing table takes a plan out of it: once new code writes child rows, a foreign key can make the old code&apos;s DELETE fail.

On 14 October 2026 we activated a Mentorship version whose build needed migrations up to 0590 against a production ledger that stood at 0573. The release record names the cause: &quot;operator did not read the production ledger before activation&quot;. Signed-in requests hit a table that did not exist yet, and we recorded seven 5xx responses in 228 runs in the incident log. The rollback verdict for the previous version was RESTRICTED, so we went forward. We first activated the matching Language version so the cross-programme gate could admit the migrations, and read its answer change from &quot;coordinated release required&quot; to &quot;independent&quot;. After a snapshot and a dry run, we applied the 17 missing migrations and read the ledger head back: 0590. The incident log sums up the policy: &quot;Rollback: none by hand. Additive tables, columns and triggers; forward recovery is the default.&quot;

## Trade-offs

Two builds trade byte identity for an explicit comparison of measured inputs. We use them because our framework&apos;s Cloudflare integration fixes the environment at build time. A dirty-tree guard is for shared checkouts: if every deploy runs in CI from a fresh checkout, it has little to catch, though you still need to record the build&apos;s inputs and the artefact&apos;s identity. Automatic rollback needs a probe that can tell &quot;broken&quot; from &quot;unreachable&quot; and from &quot;new&quot;. We can split traffic between two versions, but we have not run a canary or a blue-green switch against production and do not claim those modes as proven. And if your migrations remove or tighten anything, a code rollback can be worse than the bug it reverses.

## Verify this

In your test project, run `npx wrangler versions upload`, note the version ID, then run `npx wrangler versions deploy @100` and check that `npx wrangler deployments status` names that version and its share of traffic. For runtime version identity, add the [version metadata binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/version-metadata/) and return its `id` from a test route. If your framework generates `.wrangler/deploy/config.json`, build a second environment in the same directory and inspect that file before the next command: without `--config`, you use the configuration named there.

Our own services report where they came from. `curl -s https://language.theeducationalequalityinstitute.org/api/lc/health` and the same path on `mentorship.theeducationalequalityinstitute.org` return a `commit` field: a 12-character commit prefix, with `-dirty` for uncommitted build-relevant changes, or `unknown` when we cannot establish where a build came from. The field reports where a build says it came from; it does not show that the bytes serving traffic are identical to an artefact we tested. The build comparisons and schema decisions behind it rest on private release records.</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Cloudflare Workers</category><category>Wrangler</category><category>Deployment</category><category>Rollback</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>Durable Object alarms: stale decisions, moving deadlines</title><link>https://theeducationalequalityinstitute.org/blog/durable-object-alarms-concurrent-updates-expiring-state-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/durable-object-alarms-concurrent-updates-expiring-state-en/</guid><description>After an external await, we could overwrite newer state. By moving a deadline, we kept old watches alive. We changed both rules and the tests.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>We found that we could erase a learner&apos;s new ring while checking whether another had timed out. After reading a map from a Durable Object, we awaited the database and wrote back an older copy. Even with a fresh read, we could still apply a stale decision. In the same object, we kept reminders alive by measuring age from a deadline we moved at every check, until their history grew and, on 6 August 2026, writes failed in production. We now check each decision against the ring we evaluated and measure age from a fixed anchor. During review, we found the race; we have not measured whether it affected a real booking.

A ring starts when a learner on TEEI Language taps a volunteer who is available now. The volunteer&apos;s screen rings for two and a half minutes, as configured on 15 October 2026, and if nobody answers, we cancel the session and tell the waiting learner. We keep that time with an alarm on a Durable Object. Our overview of [how TEEI runs its platforms on Cloudflare Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/) tells the short version of both mistakes.

**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.

## One object, one alarm, two maps

TEEI Language and TEEI Mentorship are one codebase deployed as two Workers, and each Worker has one `LivePresence` Durable Object per environment. The class is [SQLite-backed](https://developers.cloudflare.com/durable-objects/api/sqlite-storage-api/) and uses the key-value API for exactly two values. `rings` maps each booking to the ring in progress, with a `claimedAt` time, an `expiresAt` derived from it, an `outcome` that starts as `&apos;pending&apos;`, and display fields such as `learnerName`. `rescueWatches` holds reminders that wake the object when a participant in a scheduled session has not arrived. Each map is stored as one value, shared by every booking in it.

A Durable Object has [one alarm](https://developers.cloudflare.com/durable-objects/api/alarms/): &quot;Each Durable Object is able to schedule a single alarm at a time by calling `setAlarm()`&quot;, and a second call overrides the first. We share it between both maps: we choose the earliest candidate and replace a future alarm only with an earlier one, so re-arming one kind of work cannot postpone the other. During `alarm()`, we read `null` from [`getAlarm()`](https://developers.cloudflare.com/durable-objects/api/sqlite-storage-api/) unless we have scheduled another alarm since the handler began, because an alarm counts as set only until it starts. With the values as of 15 October 2026, we give an expired pending ring a retry candidate five seconds from now.

The alarm has &quot;guaranteed at-least-once execution&quot;, and &quot;Only one instance of `alarm()` will ever run at a given time per Durable Object instance&quot;. One instance of the alarm is not one event at a time, though. [The state API](https://developers.cloudflare.com/durable-objects/api/state/) says: &quot;A Durable Object can run an alarm concurrently with another request&quot;.

## Storage-only work keeps the merge together

While a storage operation runs, the object&apos;s input gate, as the [glossary](https://developers.cloudflare.com/glossary/) defines it, defers every other event until the object has stopped executing JavaScript and is no longer waiting for storage. So after a storage read completes, synchronous code continues without another event interleaving. [Rules of Durable Objects](https://developers.cloudflare.com/durable-objects/best-practices/rules-of-durable-objects/) puts the boundary in one line: &quot;Input gates only protect during storage operations. Non-storage I/O like `fetch()` or writing to R2 allows other requests to interleave, which can cause race conditions&quot;.

Table: What each kind of await in our ring path does to the input gate.

| Await | Gate | Where it happens |
|---|---|---|
| `storage.get`, `storage.put` | Held | Reading and writing the rings map |
| `storage.getAlarm`, `storage.setAlarm` | Held | Pointing the alarm at the next moment |
| A call to the database (Turso, over HTTP) | Open | Asking the database to settle an expired ring |
| The notification dispatcher (database and Web Push) | Open | Delivering the missed-call notification early |
| A read from Workers KV | Open | Checking the write-freeze switch at the start of the alarm |

The same read, change and write is therefore safe in one handler and unsafe in another. When a learner claims a volunteer, the claim handler reads the rings map, adds the new ring and writes the map back, and every await in it is a storage operation. The alarm did the same thing with a database call in the middle.

## How the alarm lost a ring

Before 12 September 2026, the alarm read the rings map once. For every pending ring past its expiry, it asked the database to settle the ring and then tried to deliver the missed-call notification to the volunteer early. Then it wrote the whole map back. The input gate is open across both calls, and one object serves the whole environment, so anything another request wrote to the rings map during them was erased by the alarm&apos;s older copy.

Two shapes were possible. A ring claimed for a different booking while the alarm waited vanished. The claim had armed an alarm, but only the alarm cancels an unanswered booking on timeout, so without the stored ring, a later alarm run could not time the booking out. The other shape was the same booking: a ring answered while the alarm waited could be overwritten with the alarm&apos;s older decision.

We did not find this in production. It came out of a review of our test model on 11 September 2026, was confirmed in the code, and was fixed the next day.

![Sequence diagram in four strips, two scenarios before and since 12 September 2026. Before, the alarm reads a map holding ring A, asks the database to settle it, and while it waits another request adds ring B; the alarm then writes back its own copy, and ring B is lost. In the other scenario, ring A is answered while the alarm waits, and the whole-map write replaces the answer with the alarm’s older decision. Since 12 September 2026, the alarm records its decision with the ring it evaluated, re-reads the map using only storage operations, and applies the decision only if A’s generation still matches, so ring B is kept. When A was answered meanwhile, its outcome differs from the one evaluated, so the decision is dropped and the answer stays.](/images/blog/durable-object-alarms-concurrent-updates-expiring-state/alarm-window.svg#wide,phone,fit &quot;While our alarm waits for the database, other requests can run. Before the fix, we wrote the whole map back and erased what arrived in that window. Now we apply decisions to a fresh read, and only where the ring is still the one we evaluated. Code and tests as of 15 October 2026.&quot;)

Source: TEEI, drawn from our platform code and tests on 15 October 2026.

## A fresh read is not enough on its own

The obvious fix is to re-read the map just before writing it. That fixes the first shape: a ring claimed for another booking is in the fresh copy, so it survives. It does not fix the second. The alarm&apos;s decision for the ring it evaluated would still be applied to whatever that key now holds, and a newer stored `answered` outcome would be overwritten with the alarm&apos;s stale timeout decision.

The author of the fix checked this by putting the bug back two ways. With the whole-map write restored, two tests failed. With the fresh read kept and only the generation check removed, one test failed: the one in which the ring is answered during the alarm&apos;s await. In the commit&apos;s words, the check is &quot;independently load-bearing: a fresh read alone would have passed the cross-key case while still losing a durable answer.&quot; We did not repeat the author&apos;s September runs.

## Decisions carry the generation they were taken from

We changed how we commit the alarm&apos;s decisions. Instead of editing the map read before the external await, we collect decisions, each paired with the ring we evaluated, and commit them against the map storage holds by then.

*Adapted for publication. Shortened: the write-freeze check and the rescue-watch drain at the top, logging, error reporting, messages to open sockets and the early notification delivery are removed, and the pruning comment is rewritten to say only what the code enforces. Nothing is renamed.*

```ts
override async alarm(): Promise {
  const rings = await this.readRings();
  const now = Date.now();
  // bookingId -&amp;gt; what was evaluated, and what to write (null deletes). Never applied blindly.
  const decisions = new Map();
  for (const ring of Object.values(rings)) {
    if (ring.outcome === &apos;pending&apos; &amp;amp;&amp;amp; now &amp;gt;= ring.expiresAt) {
      let authority;
      try {
        authority = await settleSnapUnansweredCancellation(this.env, ring.bookingId, &apos;timeout&apos;);
      } catch (err) {
        continue;
      }
      if (authority === &apos;pending&apos;) continue;
      if (authority === &apos;inactive&apos;) {
        decisions.set(ring.bookingId, { evaluated: ring, next: null });
        continue;
      }
      const next = applyRingOutcome(ring, authority);
      if (!next) continue;
      decisions.set(ring.bookingId, { evaluated: ring, next });
    }
  }
  // Prune terminal rings more than an hour after their claim. Deletion is also a decision:
  // apply it only if the stored generation still matches the ring evaluated here.
  for (const [id, ring] of Object.entries(rings)) {
    if (ring.outcome !== &apos;pending&apos; &amp;amp;&amp;amp; now - ring.claimedAt &amp;gt; 60 * 60 * 1000) {
      decisions.set(id, { evaluated: ring, next: null });
    }
  }
  await this.commitRingDecisions(decisions);
  await this.armAlarm();
}
```

The alarm does not choose an outcome; the database&apos;s answer is the authority. `&apos;timeout&apos;` includes a cancellation committed by an earlier attempt whose response was lost, `&apos;answered&apos;` or `&apos;declined&apos;` means something else got there first, and `&apos;inactive&apos;` means the booking is no longer a live ring. If the call throws or answers `&apos;pending&apos;`, we record no decision; if the ring is still pending when we re-arm, it contributes the five-second retry candidate. We catch the failure ourselves rather than lean on Cloudflare&apos;s retries, which only an exception escaping `alarm()` triggers.

With no decisions, we return without reading. Otherwise we read the map once and write it at most once; every await is a storage operation:

*Verbatim excerpt.*

```ts
  private async commitRingDecisions(
    decisions: Map,
  ): Promise {
    if (decisions.size === 0) return;
    const fresh = await this.readRings();
    let changed = false;
    for (const [bookingId, { evaluated, next }] of decisions) {
      if (!ringGenerationMatches(fresh[bookingId], evaluated)) continue;
      if (next === null) delete fresh[bookingId];
      else fresh[bookingId] = next;
      changed = true;
    }
    if (changed) await this.writeRings(fresh);
  }
```

We still read and write the whole map. The input gate keeps the read, synchronous validation and write together; non-storage I/O would reopen the window after validation. We reject stale decisions through the generation check and preserve other entries through the fresh map.

What &quot;still the same ring&quot; means is one function:

*Verbatim excerpt.*

```ts
export function ringGenerationMatches(
  fresh: RingState | undefined | null,
  evaluated: RingState,
): boolean {
  if (!fresh) return false;
  return fresh.claimedAt === evaluated.claimedAt
    &amp;amp;&amp;amp; fresh.expiresAt === evaluated.expiresAt
    &amp;amp;&amp;amp; fresh.outcome === evaluated.outcome;
}
```

Cloudflare&apos;s advice here is &quot;optimistic locking (check-and-set) patterns: read a version number before the external call, then verify it has not changed before writing.&quot; We use `claimedAt`, `expiresAt` and `outcome` instead: a claim sets both times once and nothing rewrites them, and `applyRingOutcome` changes the outcome only from pending. Because we derive expiry from claim time, it adds an invariant check, not an independent identifier; detecting a replacement still requires a different claim timestamp. A unit test pins `learnerName` outside the generation, so a display change is never read as a new ring. We write a ring derived from the evaluated copy, so an excluded field must stay unchanged or keep its fresh value. If your records change in place, use a version that every write increments.

### Why not block concurrency

`blockConcurrencyWhile()` would have closed the window completely, and Cloudflare advises using it &quot;sparingly&quot; because &quot;it significantly reduces throughput&quot;. Our object is one per environment and holds every live socket, so the author of the fix ruled it out: blocking would hold back every socket message for as long as the database call and the notification delivery take. In our judgement, the choice turns on which events can wait: with an object per room, document or user, we still block that object&apos;s other events for the external call.

### Settling a ring twice is safe

Cloudflare also asks for idempotent alarm handlers: &quot;In rare cases, alarms may fire more than once.&quot; We settle the booking in the database first and update the ring in the object&apos;s storage afterwards, in two separate commits. The database call cancels only a booking that is still accepted and unanswered, and &quot;The write-once cancellation timestamp lets an alarm retry distinguish &apos;my prior cancel committed&apos; from an unrelated terminal state.&quot; When the update changes nothing or its response is lost, we read the persisted marker and outcome instead of inferring failure, with no compensating write. If the object fails between the commits and the ring remains pending, a later alarm asks again; the generation check applies the answer only to the state we evaluated.

We record the missed-call notification as a durable job in the same database transaction as the cancellation. The insert may do nothing on a retry, so a separate statement checks that the persisted timeout has the matching job before the batch can commit. The alarm only attempts an early delivery; a failure there is left for the scheduled job. What we claim here covers settling a ring and that notification job.

## A test double that shared a reference

We missed the race because of the fake storage our tests ran against. Its `put` cloned the value, but its `get` returned the stored object itself. When a test called the claim handler from inside the alarm&apos;s await, both handlers held the same object, so the new ring appeared in the alarm&apos;s map by reference. In memory there was only one map, so nothing was ever lost. A control test written earlier to catch this very defect passed against the broken product code on that harness; we could have mistaken that pass for proof of a fix. So we repaired the harness first:

*Adapted for publication. The comment is shortened to its first sentence, and the declaration of a second store above the excerpt is removed.*

```ts
  const storage = {
    // CLONE ON READ, not just on write.
    get: vi.fn(async (key: string) =&amp;gt; (
      key === &apos;rings&apos; ? structuredClone(rings) : structuredClone(otherKeys[key]) as unknown
    )),
    put: vi.fn(async (key: string, value: unknown) =&amp;gt; {
      if (key === &apos;rings&apos;) rings = structuredClone(value) as Record;
      else otherKeys[key] = structuredClone(value);
    }),
```

We model each read as an independent copy; we have not verified that behaviour in the real runtime. Cloudflare&apos;s storage documentation says that `put` accepts &quot;any type supported by the structured clone algorithm&quot;, not that each `get` returns a new copy.

![Two panels. In the first, the fake storage hands both handlers the same object, so a ring the request adds shows up in the alarm’s map too, and a test for the lost update passes against the broken code. In the second, the fake hands each handler its own copy, so the alarm’s map does not contain the new ring, and the same test fails until the code is fixed.](/images/blog/durable-object-alarms-concurrent-updates-expiring-state/clone-on-read.svg#wide,phone,fit &quot;When our fake returned the stored object, both handlers shared one map, and our test could not expose the lost update. Results from the author’s runs on 12 September 2026.&quot;)

Source: TEEI, drawn from our test harness and the author&apos;s runs on 12 September 2026.

With the harness repaired and the product code unchanged, two new tests failed. With the fix, in the author&apos;s run on 12 September 2026, 208 tests passed, and the 48 presence tests that existed before passed unchanged against the cloning harness. The first new test claims a second booking from inside the mocked database call, exactly where the input gate would be open:

*Adapted for publication. Three fields of the claim that name its participants are removed.*

```ts
  it(&apos;keeps a ring claimed while the alarm was awaiting the database&apos;, async () =&amp;gt; {
    const run = harness(expiredRing());
    h.settle.mockImplementationOnce(async () =&amp;gt; {
      await notify(run, {
        kind: &apos;claimed&apos;,
        bookingId: &apos;second-booking&apos;,
        claimedAt: 2_900,
      });
      return &apos;timeout&apos;;
    });

    await run.presence.alarm();

    expect(run.rings()[&apos;second-booking&apos;]).toBeDefined();
    expect(run.rings()[&apos;second-booking&apos;]?.outcome).toBe(&apos;pending&apos;);
    // The alarm&apos;s own decision still lands for the ring it actually evaluated.
    expect(run.rings()[&apos;snap-booking&apos;]?.outcome).toBe(&apos;timeout&apos;);
  });
```

The second delivers an answer inside the await while the mocked database still says `&apos;timeout&apos;`. Real database authority would have said `&apos;answered&apos;`, so the test stages a conflict production would not produce; it pins the rule that a decision never undoes a newer outcome. Both run in Node against hand-written fakes, under a model in which non-storage awaits let other events in; they do not exercise the real gate or real storage. Cloudflare&apos;s [testing guide for Durable Objects](https://developers.cloudflare.com/durable-objects/examples/testing-with-durable-objects/) describes `runInDurableObject()` and `runDurableObjectAlarm()` in the Workers pool for Vitest, which run against the real runtime; we have not moved these tests there.

## A watch that never grew old

The second mistake was a rule about time. We create a watch when the first person arrives in the room for a booking, with its first boundary as `dueAtMs`. At that boundary, the alarm calls code that reads the booking and who is present from the database and alerts the missing participant if needed. With the configuration as of 15 October 2026, we then record the boundary as evaluated and advance the watch five minutes to the next one, and we measure retirement from its first boundary, with a one-hour threshold.

Until 6 August 2026, the retirement rule compared the current time with `dueAtMs`, the boundary being processed, and only then stored the next boundary. When the alarm fired on time, the current time was that boundary, so the difference was zero at every run. A drain more than an hour late would have retired the watch; on-time drains kept it alive, adding one number to its list every five minutes for as long as the object lived.

![Two timelines of a watch with an alarm that fires exactly on time every five minutes. Under the old rule, the age measured against the boundary being processed is zero at every run, so it never reaches one hour, and the list of checked boundaries grows by one every five minutes without end. Under the new rule, the age measured from a fixed anchor rises by five minutes each run, crosses one hour at the run 65 minutes in, and on this on-time path the watch retires on the fourteenth run with at most thirteen boundaries in its list, well under the cap of 24 applied when the value is read.](/images/blog/durable-object-alarms-concurrent-updates-expiring-state/anchor-vs-deadline.svg#wide,phone,fit &quot;With alarms exactly on time, the old comparison stays at zero. In our test of the fixed rule, we retire the watch on run fourteen. Code and test as of 15 October 2026.&quot;)

Source: TEEI, drawn from our platform code and its test on 15 October 2026.

On 6 August 2026, the object&apos;s production writes began failing with `SQLITE_TOOBIG` on a Worker version unchanged for five days; as the fix&apos;s author put it, &quot;the data grew.&quot; All watches shared one stored value, and &quot;since pruning also needs a write, nothing could shrink it back.&quot; We did not measure the stored value&apos;s size, identify the limit it hit or establish why the errors stopped. [Cloudflare&apos;s limits page](https://developers.cloudflare.com/durable-objects/platform/limits/), as documented on 15 October 2026, gives 2 MB for a key and value combined on SQLite-backed storage and 128 KiB per value on the older key-value backend; our sources do not establish which applied. The errors stopped before the fix was committed, and more than an hour after the commit our notes still recorded it as not deployed, so we do not credit the fix with ending the incident.

## A fixed anchor, and a cap applied on read

We stamp `anchorMs` once, from the first boundary the watch is armed for, and never rewrite it. If a second person opens the room and pulls the boundary earlier, the watch takes the earlier boundary and keeps its anchor: in the commit&apos;s words, &quot;an earlier boundary from a second arrival wins the alert but buys no extra life.&quot; Retirement measures age from the anchor:

*Adapted for publication. This is the end of one pass of a loop over the watches. A comment about how the next boundary is chosen and the write after the loop are removed, and one phrase in the remaining comment is made more precise.*

```ts
      const evaluated = [...watch.evaluatedDueAtMs, watch.dueAtMs].slice(-MAX_EVALUATED_BOUNDARIES);
      const nextDueAtMs = watch.dueAtMs + 5 * 60_000;
      // Age is measured from the fixed anchor, NEVER from dueAtMs, which steps forward on
      // every drain. Against a moving target the difference stays near zero for as long as the
      // alarm keeps firing on time, so the watch never retires and its list never stops growing.
      if (nowMs - watch.anchorMs &amp;gt; RESCUE_WATCH_TTL_MS) delete watches[bookingId];
      else {
        watches[bookingId] = {
          bookingId,
          anchorMs: watch.anchorMs,
          dueAtMs: nextDueAtMs,
          evaluatedDueAtMs: evaluated,
        };
      }
```

The rings never had this problem. A finished ring becomes eligible for pruning once more than an hour has passed since `claimedAt`, which is written once; we schedule no separate timer for it and prune it when a later alarm run reaches cleanup. The watches measured from a field that existed to move. When a record has to expire, ask whether your own code ever changes the timestamp you measure from. If it does, the record can stay young forever.

The anchor bounds how long a watch lives, not how many boundaries it collects. A separate cap does that, also for watches already stored. On read, before the alarm processes a watch&apos;s due boundary, we keep the last 24 numeric entries of its history (the cap as of 15 October 2026), in evaluation order, not necessarily timestamp order. We still fetch the complete stored map and filter each complete history first, so we bound the retained history, not the amount read.

*Adapted for publication. The two comments are rewritten to say only what the code enforces.*

```ts
      const evaluated = watch.evaluatedDueAtMs.filter((at) =&amp;gt; typeof at === &apos;number&apos;);
      watches[bookingId] = {
        bookingId: watch.bookingId,
        // Reconstruct a missing anchor before truncating the stored history. The first
        // entry is the fallback, not necessarily the minimum.
        anchorMs: typeof watch.anchorMs === &apos;number&apos; ? watch.anchorMs : (evaluated[0] ?? watch.dueAtMs),
        dueAtMs: watch.dueAtMs,
        // Bound each retained history independently of retirement.
        evaluatedDueAtMs: evaluated.slice(-MAX_EVALUATED_BOUNDARIES),
      };
```

A watch written before anchors existed takes the first numeric entry of its untrimmed history, or its current boundary if that history is empty, so it needs no migration; taken from the trimmed tail, the anchor would be a later boundary. A watch that had been growing for days gets an anchor days old and retires the next time the alarm processes its due boundary; the reconstructed anchor exists only in memory until a later write persists it. No surviving watch is written back with more than 24 boundaries. In the commit&apos;s words, &quot;trimming on READ is what lets a deploy recover the object instead of the stored value having to be purged by hand&quot;; that is the design and what the tests show. Neither step guarantees on its own that the combined map fits under the limit, and we have not shown recovery against a real size rejection, because our fake storage refuses nothing.

### Drain until it is gone

The test that guards the anchor runs the alarm at every boundary, exactly on time, until the watch disappears:

*Adapted for publication. An assertion on the stored anchor and the comment above the loop are removed.*

```ts
  it(&apos;retires a watch an hour after the boundary it was first armed for&apos;, async () =&amp;gt; {
    const anchor = T0 + 3 * 60 * 1000;
    vi.spyOn(Date, &apos;now&apos;).mockReturnValue(T0);
    const lp = harness();
    await lp.arm(&apos;booking-1&apos;, anchor);

    let drains = 0;
    let longestList = 0;
    while (lp.watches()[&apos;booking-1&apos;] &amp;amp;&amp;amp; drains &amp;lt; 100) {
      vi.spyOn(Date, &apos;now&apos;).mockReturnValue(lp.watches()[&apos;booking-1&apos;].dueAtMs);
      await lp.presence.alarm();
      drains += 1;
      const still = lp.watches()[&apos;booking-1&apos;];
      if (still) longestList = Math.max(longestList, still.evaluatedDueAtMs.length);
    }

    expect(lp.watches()[&apos;booking-1&apos;]).toBeUndefined();
    // TTL / STEP boundaries plus the drain that crosses the deadline.
    expect(drains).toBe(TTL_MS / STEP_MS + 2);
    // The anchored TTL is what bounds the list; the read-side ceiling never has to bite.
    expect(longestList).toBeLessThan(MAX_EVALUATED);
  });
```

In our on-time test of 15 October 2026, we retire the watch on run fourteen, 65 minutes after its anchor, with at most thirteen boundaries retained. A short list is not evidence of expiry: a cap can bound the history while leaving the watch alive indefinitely, so the test checks that the watch disappears and the exact number of drains. With the old comparison, the 100-drain guard would stop the loop while the watch is still present, so the absence assertion would fail. The author put the old comparison back on 6 August 2026 and saw two of the four new tests fail; we did not repeat that run. Two more tests start from a 5,000-boundary fixture, not a production measurement, and check that the first drain clears it and that a surviving watch writes small.

## When this is the wrong pattern

**In our judgement, a skipped decision has to be cheap to take again.** We can ask the database again for a ring that remains pending after we skip a stale decision, and a ring someone else settled needs nothing from us. If your decision has already caused a side effect outside the object that cannot be repeated or undone, that side effect has to be idempotent or recorded with the decision.

**One value per map is the whole map every time.** For rings, each change reads and writes the whole map. Keeping a map as one value is also why one record&apos;s growth could stop every write in the object. We considered one storage key per booking and have not done it. Our thresholds limit each entry, not the number of entries; if yours cannot be bounded that way, or there can be many, separate keys or the object&apos;s SQL tables are the better shape.

**Two jobs on one alarm share their failures.** The timeout and the watches wake on the same alarm, and the watches run first. So a malformed watch value &quot;must degrade to &apos;no watches&apos;, never throw and take the ring timeout down with it&quot;, and a failure in a watch&apos;s work is caught, logged and left for the scheduled job. After a failed drain we still advance or retire the watch; the evaluated list records attempts, and delivery state in the database decides what is retried. If your second job can fail in ways you do not control, give it its own object.

## Verify this

Two reproductions you can run in your own project:

- **The test double.** In a Vitest fake for `ctx.storage`, make `get` return the stored object and call a second handler from inside a mocked await. The race test passes against the broken code. Make `get` return `structuredClone` of the stored value, and it fails until the code is fixed.
- **The moving deadline.** Compare `now` with a deadline you step forward on each call, run it exactly on time at each step, and count the steps until it retires. It never does. Then measure from a fixed anchor.

## What we ran for this note

On 15 October 2026 we ran four selected test files for this state machine: presence, rescue watches, write freeze and the protocol unit tests. All 52 tests passed in 19.8 seconds on Vitest 4.1.11, on fakes in Node. The constants above are from the source revision tested that day; results from the original authors&apos; runs are marked as theirs.

[Real-time availability with Durable Objects](/blog/real-time-availability-with-durable-objects-en/) describes the same object from the booking side. The missed-call notification follows the shape of [our email outbox behind Cloudflare Queues](/blog/reliable-email-cloudflare-queues-transactional-outbox-en/): the decision and the duty to tell someone are written together.</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Durable Objects</category><category>Cloudflare Workers</category><category>Concurrency</category><category>Testing</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>Why our certificate repairs need a new R2 address</title><link>https://theeducationalequalityinstitute.org/blog/from-completed-sessions-to-certificates-browser-rendering-r2-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/from-completed-sessions-to-certificates-browser-rendering-r2-en/</guid><description>Until September 2026 a repair could overwrite badge images that readers had already cached as immutable. We publish every repair at a new address in R2.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>Until 24 September 2026, a repair of a credential&apos;s badge artwork could overwrite files we had already served as immutable. Anyone who had cached the broken image could keep seeing it for up to a year. We did not measure how many readers that was. Now we write every repair to a new generation in Cloudflare R2 and point the verification page at the generation that is ready. We draw the badges and PDF certificates with Chromium on AWS Lambda, and in our code we hold every delivery until all four files are stored. We can repair an object in R2 without repairing the copy a reader already holds.

A credential at TEEI recognises verified participation on TEEI Language or TEEI Mentorship: a PNG badge in three sizes, an A4 certificate as a PDF and a page anyone can open from a link. It is not a course and it is not a qualification. This note is part of the series that starts with [How TEEI Runs Its Platforms on Cloudflare Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/). In that article we describe the Workers; here we follow the drawing work off Cloudflare and back into R2.

**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.

## A repair that the people who had seen the bug could not see

Until that day we picked generation 1 for any drawing job that was not ready. When staff requeue a drawing job for repair, we clear its recorded generation. So the repaired artwork was written over the very generation 1 objects it was meant to replace. Those objects were served at the time as `public, max-age=31536000, immutable`, and an [immutable response](https://developers.cloudflare.com/cache/concepts/cache-control/) is one a browser keeps without asking again. R2 was not the problem: its [writes are strongly consistent](https://developers.cloudflare.com/r2/reference/consistency/), so any read from the bucket after the write sees the repaired bytes. A browser that holds a fresh, immutable copy does not ask again, so we give the repair a new address rather than wait for that copy to expire.

We now ask the bucket, not the job row, because the row is exactly what a requeue erases:

*Verbatim excerpt.*

```js
export function isNotFound(err) {
  const status = err?.$metadata?.httpStatusCode;
  return status === 404 || err?.name === &apos;NotFound&apos; || err?.name === &apos;NoSuchKey&apos;;
}

export async function firstFreeGeneration({ exists, keyFor, start = 1 }) {
  const from = Number.isInteger(start) &amp;amp;&amp;amp; start &amp;gt; 0 ? start : 1;
  for (let generation = from; generation &amp;lt; from + MAX_GENERATION_PROBES; generation++) {
    if (!(await exists(keyFor(generation)))) return generation;
  }
  throw new Error(`credential_artwork_generation_exhausted:${keyFor(from)}`);
}
```

We probe only the main badge, the first file we upload. Because we upload the four files one after another, a run that fails partway still leaves that generation occupied. The search fails closed: only a definite &quot;not found&quot; frees a generation, while a permissions error or a network error throws, and that credential waits for the next run instead of overwriting a published object on a guess. In the code we read on 15 October 2026, we stop after 50 probes. In five unit tests we check, among other things, that a requeued credential never reuses a published generation and that an unknown error never frees one.

![Two rows. Before 24 September 2026, when staff requeued a job for repair we cleared its generation, and the repaired artwork was written over generation 1, which browsers had cached as immutable, so they kept the broken image. After the fix, we ask the bucket for the first generation whose main badge does not exist yet, skip generation 1 because it exists, and write generation 2 beside it; we leave generation 1 as it was, and the verification page points to generation 2. If the bucket check fails with anything other than a definite not found, we stop for that credential and try again on the next run.](/images/blog/from-completed-sessions-to-certificates-browser-rendering-r2/new-generation.svg#wide,phone,fit &quot;We draw a new generation beside the old one and point the verification page at it when ready.&quot;)

Source: TEEI code and commit history, 24 September 2026.

The fix landed five days before the move to Lambda, and the rendering script we run on Lambda uses the same generation search. On the public verification page we show the generation that is ready, which is how a holder sees the repaired version. A new generation does not change the address of anything already shared; the page that points at the current generation has to carry the repair.

## Publishing a generation: four files, four ETags, then one row

A new address is only safe if nothing points people at it before its files exist.

The credential starts in the platform that runs [TEEI Language](/programmes/education/teei-language/) and [TEEI Mentorship](/programmes/education/teei-mentorship/), a React Router 8 application on Workers. In our code as of 15 October 2026, on a cron trigger every quarter of an hour, we issue at most 25 credentials to people who have passed a threshold of completed, verified sessions in one programme; the cap counts credentials issued, not people looked at. We write the issuance claim, credential, drawing job and deliveries in one atomic database batch. For a claim we own, we make its final statement raise a database error unless it finds the credential, one queued drawing job and the expected number of deliveries, so the batch fails as a whole. We insert the drawing job from the claim itself, so this insertion queues artwork only for the claim owner, and is safe to run twice. If you have read our [note on a transactional outbox](/blog/reliable-email-cloudflare-queues-transactional-outbox-en/), the shape is familiar: the business fact and the work it causes are written together, or not at all.

We hold credential deliveries until all four primary files have been stored. In the same batch we always queue an in-app notification delivery, and an email delivery when credential emails are switched on; we dispatch neither at issuance. Those deliveries wait until the drawing job is ready, and so do the push we send with the notification and the artwork we show for sharing. Ready has a strict definition in SQL. The fragment below shows the readiness conditions; we leave out the expression that checks the four expected object keys and mark where it goes:

*Adapted for publication.*

```sql
-- URL strings never participate in readiness.
    artwork.status = &apos;ready&apos;
AND coalesce(length(trim(artwork.badge_800_etag)), 0) &amp;gt; 0
AND coalesce(length(trim(artwork.badge_square_etag)), 0) &amp;gt; 0
AND coalesce(length(trim(artwork.badge_og_etag)), 0) &amp;gt; 0
AND coalesce(length(trim(artwork.certificate_pdf_etag)), 0) &amp;gt; 0
AND artwork.artifact_generation &amp;gt; 0
AND coalesce(length(trim(artwork.renderer_release)), 0) &amp;gt; 0
AND length(artwork.template_sha256) = 64
AND artwork.template_sha256 = lower(artwork.template_sha256)
AND artwork.template_sha256 NOT GLOB &apos;*[^0-9a-f]*&apos;
AND /* omitted: the four object keys are the ones this job expects */
```

A missing ETag counts as length zero, so a status of `&apos;ready&apos;` alone cannot release a delivery. We record an ETag only from R2&apos;s answer to an upload and stop without one, so a row with four non-empty ETags, a generation, a renderer release and a well-formed template hash describes four files that were stored. In our code, we release credential emails only after all four files are stored; we have not measured this guarantee in production, and the unit tests for this module cover its state, URL derivation and enqueueing, not the email itself.

On AWS we write to R2 through its [S3-compatible API](https://developers.cloudflare.com/r2/api/s3/api/) with the ordinary AWS SDK, so code that runs outside Cloudflare can write to a bucket that a Worker reads through its [binding](https://developers.cloudflare.com/r2/api/workers/workers-api-reference/). The order of operations is the contract:

*Adapted for publication.*

```js
// Never write over a published generation (see the previous section).
const generation = await firstFreeGeneration({
  exists: publishedObjectExists,
  keyFor: (g) =&amp;gt; keysFor(credential, g)[&apos;badge-800&apos;],
  start: generationHint,
});
const keys = keysFor(credential, generation);
const buffers = await renderCredentialArtefacts(browser, credential, {
  history,
  onFontCheckFail: (kind, face) =&amp;gt; {
    throw new Error(`font_check_failed:${kind}:${face}`);
  },
});
const etags = {};
for (const artefact of ARTEFACTS) {
  etags[artefact.kind] = await uploadAndGetEtag(keys[artefact.kind], buffers[artefact.kind], contentTypeOf(artefact));
}
await publishReadyRow({ credential, generation, keys, etags });

async function uploadAndGetEtag(key, body, contentType) {
  // Safe to retry: same key, same bytes, so a repeated PUT is the same object.
  const res = await withRetry(&apos;r2 put&apos;, () =&amp;gt;
    s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: body, ContentType: contentType })),
  );
  const etag = stripQuotes(res.ETag);
  if (!etag) throw new Error(&apos;r2_upload_no_etag&apos;);
  return etag;
}
```

We make issuance atomic inside the database. Artwork has a different contract, because it spans two stores: we upload the files first, without holding a database transaction open, and then publish the generation, all four object keys and all four ETags in one database write, the ready-row write. If we stop before it, we leave unused objects in R2 but release no deliveries for a new credential; we never publish a partial set.

When we redraw a credential that is already ready (a new look, or a new release), its old ready row stays in force until the new row replaces it. After publishing the row, we draw WebP contact sheets for the look picker; we exclude them from readiness. We upload the last sheet at the end, so its presence means the set is complete, and as of 15 October 2026, on later runs we redraw a missing set for credentials made ready in the past two days.

![Sequence from issuing a credential to releasing its deliveries. Every 15 minutes, on the platform Worker, we issue at most 25 credentials and write each credential, its drawing job and its deliveries in one database batch; the deliveries wait. Every five minutes, on AWS Lambda, we draw the four files, upload them to Cloudflare R2, collect four ETags and write a ready row. Only then do we release the queued deliveries.](/images/blog/from-completed-sessions-to-certificates-browser-rendering-r2/ready-gate.svg#wide,phone,fit &quot;Two schedules, one gate: in our code, no delivery leaves before its four files exist.&quot;)

Source: TEEI code, 15 October 2026.

As of 15 October 2026, we issue every 15 minutes and render every 5; we have not timed the whole path from a completed session to a finished image.

## Drawing on Lambda

We run the renderer as a [Lambda function packaged as a container image](https://docs.aws.amazon.com/lambda/latest/dg/images-create.html) with Playwright and Chromium inside, deployed on 29 September 2026. Before that, the same script ran on a workstation on a timer. We reserve [concurrency](https://docs.aws.amazon.com/lambda/latest/dg/configuration-concurrency.html) of 1 for this function, so its invocations run one at a time; that matters because finding a free generation does not reserve it. We turn retries off on both the [asynchronous invocation](https://docs.aws.amazon.com/lambda/latest/dg/invocation-async-error-handling.html) and the [schedule](https://docs.aws.amazon.com/scheduler/latest/UserGuide/managing-schedule-retry-policy.html), and we discard a scheduled tick after two minutes of waiting, where Lambda&apos;s default would keep it for up to six hours, because we check for outstanding work again on the next run. Neither setting promises that a run is delivered exactly once, so we make each run safe to repeat.

In our configuration on 15 October 2026, we watch the function with three CloudWatch alarms: one for repeated errors, one for a missed schedule, and one for a run that comes close to its timeout. We bind the mode to the stage in code: staging is a dry run that writes nothing, and in a test we check that the stack offers no property to set the mode from outside.

We start the browser with the usual flags for Chromium on Lambda and leave one out on purpose: `--single-process`. In a launch-code comment we record that we saw that flag change how type is rasterised on our Story card image, and on a certificate the type is the product.

Each credential becomes four files: `badge-800` (800 by 800), `badge-square` (400 by 400), `badge-og` (1200 by 630) and `certificate.pdf` (A4 landscape). We draw three of them in Chromium, one short-lived page each, and keep the results as buffers in memory. Before we keep a drawn file, we check that the display family has loaded:

*Verbatim excerpt.*

```js
      await page.setContent(html, { waitUntil: &apos;load&apos; });
      await page.evaluate(() =&amp;gt; document.fonts.ready);

      const hasWords = await page.evaluate(() =&amp;gt; (document.body?.innerText ?? &apos;&apos;).trim().length &amp;gt; 0);
      if (hasWords) {
        const displayFaceLoaded = await page.evaluate(
          (face) =&amp;gt; document.fonts.check(`16px &apos;${face}&apos;`),
          expectedFace
        );
        if (!displayFaceLoaded) {
          onFontCheckFail?.(artefact.kind, expectedFace);
        }
      }
```

[`document.fonts.ready`](https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet/ready) resolves when the fonts the page has asked for have finished loading, but it resolves just as happily when a face failed and the browser fell back to a system serif. [`document.fonts.check`](https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet/check) is the question that catches the fallback: it returns false while a face the page declared has not loaded. For a face the page never declared, it returns true, so it guards declared faces only. We embed the fonts in the HTML and check the declared regular Lora face; we do not verify every weight or script subset. In production, we abort the render when the display font check fails: we upload none of that credential&apos;s new primary files, and we retry on a later run. We draw the look picker&apos;s sheets with the same check after the ready row; a failure there fails the run, but the primary files are already ready.

The square badge is the one file we do not draw as a page. Until 5 October 2026 it was the same card under CSS `zoom: 0.5`, and at that scale Chromium spaced the letters of the verification line unevenly. Now we scale the 800-pixel badge down with sharp.

A run must also end before its timeout. We added the loop guard on 5 October 2026, after a stop could land between a credential&apos;s ready row and its look picker&apos;s sheets, leaving a row that read complete while its sheets were missing. We time each attempt from choosing a generation through those sheets, failures included. We assume 30 seconds before the first measurement, then use the slowest attempt; we start another credential only while 1.5 times that still fits before a deadline 35 seconds ahead of the Lambda timeout, and the rest wait for the next run. Separately, we ask the rendering process to terminate 20 seconds before the timeout and force it five seconds later. In our boundary tests we start an exact fit and reject one millisecond over.

## Two buckets, two contracts

Generations protect a repair. Revocation needs something else. The credential files and the session share cards live in two R2 buckets, and in our code as of 15 October 2026 we give revocable credentials a short cache lifetime and public share cards immutable addresses.

![Two columns compare programme credential files and session share cards. Programme credential files are written by the credential renderer on AWS Lambda, drawn in Chromium with the square badge scaled by sharp, set by our code to one hour and never immutable, and can be revoked. Share cards are written by a separate card renderer into a separate bucket, drawn with Satori and sharp, cached for a year as immutable, and have no revocation mechanism in our design; a missing card is never cached.](/images/blog/from-completed-sessions-to-certificates-browser-rendering-r2/two-buckets.svg#wide,phone,fit &quot;Two buckets, two contracts: in our code we set a one-hour cache lifetime for credential files and never mark them immutable; for session share cards we set one year and immutable, and we never cache a missing card.&quot;)

Source: TEEI code, 15 October 2026.

**Credentials can be revoked.** So for credential files we chose a short cache. Our code for them sets a one-hour `max-age` and never marks a file immutable:

*Adapted for publication.*

```ts
// One hour and never immutable: a later revocation must not stay pinned in a cache
const cacheControl = &apos;public, max-age=3600&apos;;
```

For responses that carry this header, one hour is the freshness lifetime. We cannot change copies that a browser or proxy cached earlier: once a copy has left, we cannot control how long it lives.

**We chose not to make share cards revocable.** A session share card describes a session that is already public. Its address changes whenever its content or layout changes, and we never write over a card that already exists. We send `PutObject` with `IfNoneMatch: &apos;*&apos;`, so an upload succeeds only if the key is absent; if another request has stored the card first, we keep that card. So an address always names the same file and the card can be served `public, max-age=31536000, immutable`. The design treats published artwork as a fixed public artefact. A new address preserves the earlier artefact; it does not recall copies that have already been cached or shared. And we never cache a missing card:

*Verbatim excerpt.*

```ts
// A missing card may be written a moment later, so a 404 must never be cached anywhere.
function notFound(): Response {
  return new Response(&apos;Not found&apos;, {
    status: 404,
    headers: { &apos;Content-Type&apos;: &apos;text/plain; charset=utf-8&apos;, &apos;Cache-Control&apos;: &apos;no-store&apos; },
  });
}
```

A card link can be shared before the renderer has finished. If the first request had cached a 404 for a year, the card could stay invisible to that reader until the cached miss expired.

We draw the cards in a separate Lambda service, with Satori and sharp rather than Chromium. We keep the card renderer&apos;s write access separate from credentials, so no bug in it can overwrite a badge someone has put on their LinkedIn profile.

## A note on Cloudflare Browser Run

Cloudflare&apos;s headless browser service was called Browser Rendering until [15 April 2026, when it was renamed Browser Run](https://developers.cloudflare.com/changelog/post/2026-04-15-br-rename/). Our admin configuration retains a Browser Run binding for badge and certificate tools, through [Quick Actions](https://developers.cloudflare.com/browser-run/quick-actions/). A binding is not evidence of use, and we have not measured any: Cloudflare&apos;s GraphQL dataset for Browser Rendering events returned no rows for 20 September to 15 October 2026, and we do not know whether that dataset counts Quick Actions. In August 2026 we also built a renderer Worker for the programme credentials on Browser Run. In its template we require every listed font to load at least one face and pass `document.fonts.check`, and every image to finish with a non-zero natural width, which is stricter than the check our Lambda renderer runs. We have not deployed it. We have no recorded comparison of the two renderers and have not measured one against the other.

## When this is the wrong pattern

- **If nobody has cached your files yet, overwriting is simpler than generations.** Generations exist because an overwrite cannot replace the copies that readers have already cached at the same address. The price is that a repair does not change the address of anything already shared, and a failed attempt can leave unused objects behind: once its first upload has occupied a generation, the next attempt chooses another one rather than completing that partial set.
- **If each address names fixed content that you will never withdraw, make it immutable.** Revocable files cost you a short cache lifetime. We designed our share cards not to need one.
- **If people expect the image at once, a schedule is wrong.** A batch every five minutes, with the deliveries held until the files exist, trades immediacy for drawing outside anyone&apos;s request. That suits a certificate. It does not suit an interactive preview.

## Verify this

**Reproduce the font check and the PDF (public reproduction).** In a project of your own, install `playwright@1.59.1` and its bundled Chromium with `npm install --save-exact playwright@1.59.1` and `npx playwright install chromium`. Save the script below as `reproduce.mjs`, and place a regular Lora WOFF2 file beside it as `lora-latin.woff2`; [the local font fixture](/fonts/lora-latin.woff2) is the face used in this example. We embed that file in the HTML, so rendering makes no font request to another site. The script loads the HTML, waits for fonts, checks the display face and prints A4 landscape with backgrounds.

*Adapted for publication. Revised on 22 October 2026 to use a local font fixture and expose the three control variants as arguments.*

```js
import { readFile } from &apos;node:fs/promises&apos;;
import { chromium } from &apos;playwright&apos;;

const variant = process.argv[2] ?? &apos;loaded&apos;;
if (![&apos;loaded&apos;, &apos;broken&apos;, &apos;undeclared&apos;].includes(variant)) {
  throw new Error(&apos;Use loaded, broken or undeclared&apos;);
}
const font = (await readFile(new URL(&apos;./lora-latin.woff2&apos;, import.meta.url))).toString(&apos;base64&apos;);
const fontRule = variant === &apos;undeclared&apos; ? &apos;&apos; : `
  @font-face {
    font-family: &apos;Lora&apos;;
    src: url(data:font/woff2;base64,${variant === &apos;broken&apos; ? &apos;AAAA&apos; : font}) format(&apos;woff2&apos;);
    font-weight: 400;
    font-style: normal;
  }`;
const html = `
  
&lt;p&gt;Certificate of participation&lt;/p&gt;`;

const browser = await chromium.launch();
try {
  const page = await browser.newPage();
  await page.setContent(html, { waitUntil: &apos;load&apos; });
  await page.evaluate(() =&amp;gt; document.fonts.ready);
  const ok = await page.evaluate((face) =&amp;gt; document.fonts.check(`16px &apos;${face}&apos;`), &apos;Lora&apos;);
  console.log({ variant, fontLoaded: ok });
  if (!ok) throw new Error(&apos;font_check_failed&apos;);
  await page.pdf({ path: `certificate-${variant}.pdf`, format: &apos;A4&apos;, landscape: true, printBackground: true,
    margin: { top: &apos;0&apos;, bottom: &apos;0&apos;, left: &apos;0&apos;, right: &apos;0&apos; } });
} finally {
  await browser.close();
}
```

Run `node reproduce.mjs loaded`, then `node reproduce.mjs broken`, then `node reproduce.mjs undeclared`. The broken variant declares a deliberately invalid font data URL: `document.fonts.ready` still resolves, the check should return false and the script should stop before writing a PDF. The undeclared variant removes `@font-face` altogether, so the check should return true even though no Lora face was declared. It guards declared faces only. In our original experiment at 14:04 UTC on 15 October 2026, using Playwright 1.59.1 and its bundled Chromium, the loaded, failed and undeclared cases returned true, false and true. That historical run used an external stylesheet for the loaded case. We repeated the revised local-font example on 22 October 2026 with Playwright 1.59.1 and its bundled Chromium: true, false and true again; the broken case stopped with `font_check_failed` before producing a PDF.

## Further reading

For one-to-one sessions, we require at least five minutes connected together, as we explain event by event in our [Amazon Chime SDK note](/blog/from-practice-room-to-company-report-teei-amazon-chime-sdk-en/). What a holder sees is described in [How You Earn Credentials with TEEI Language](/blog/teei-volunteer-certification-system-en/) and on the public explainers for [TEEI Language](https://language.theeducationalequalityinstitute.org/language-certification) and [TEEI Mentorship](https://mentorship.theeducationalequalityinstitute.org/mentorship-certification). How one application serves both programmes is in [one codebase, two programmes](/blog/one-codebase-two-programmes-react-router-8-cloudflare-workers-en/).</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Cloudflare R2</category><category>AWS Lambda</category><category>Playwright</category><category>Caching</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>How TEEI Runs Its Platforms on Cloudflare Workers</title><link>https://theeducationalequalityinstitute.org/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/</guid><description>TEEI&apos;s website, TEEI Language, TEEI Mentorship and Evenhall run on Cloudflare Workers. This is how, with what broke and what we measured.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>A learner picks a volunteer who is free right now, and the volunteer&apos;s screen rings. A company manager invites employees into TEEI&apos;s programmes, and each invitation is saved before an email goes out. Both run on Cloudflare Workers, like the rest of TEEI&apos;s platforms: the website, [TEEI Language](/programmes/education/teei-language/), [Skills Academy](/programmes/education/skills-academy/), [TEEI Mentorship](/programmes/education/teei-mentorship/), [Evenhall](/partners/evenhall/) and the admin our own staff work in, all on one Cloudflare account. In Cloudflare&apos;s sampled analytics for the 30 days to 14 October 2026, our production Workers answered about 5.44 million requests, and about 120 of them ended in a runtime error.

TEEI is a nonprofit. Our free programmes connect people from Ukraine, and women from Latin America and the Caribbean, with volunteers from many countries who practise a language with them, mentor them and teach live classes. Companies take part through Evenhall, where their managers see what their employees did through TEEI&apos;s programmes and turn it into a report they can file.

This is an engineering note about what all of that runs on: how the account is laid out, the two days in June when we moved everything onto it, the Durable Object that rings a volunteer&apos;s screen, why our Workers run next to the database instead of next to the user, the queues behind Evenhall, the limits we hit, and how we deploy. Every number comes from our Cloudflare account, our logs or our code. We measured them on 14 October 2026 unless we say otherwise, and the last section lists what you can check yourself.

**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 people do on these Workers

Most of this note is about Workers, bindings and limits. This is what they carry. A learner can find TEEI on the website, practise a language on [TEEI Language](https://language.theeducationalequalityinstitute.org/), talk to a volunteer in a practice room, and meet a mentor on [TEEI Mentorship](https://mentorship.theeducationalequalityinstitute.org/). Each of those screens is answered by a different Worker, or by the same Worker doing a different job.

![Four phone screens in a row, joined by arrows. First, the TEEI website&apos;s home page with the words Connection, Language, Skills, Work and a button that says I want to learn. Second, the home screen of TEEI Language for a sample learner, with a goal, sample volunteers and the next session. Third, a one-to-one practice session on a phone between demo personas, with the video in the middle and the controls along the bottom. Fourth, the home screen of TEEI Mentorship for a sample mentee, with the next mentoring session. Under each screen, the Worker that serves it and the Cloudflare feature it relies on.](/images/blog/teei-cloudflare-workers/journey-screens.svg#wide &quot;An example path through TEEI, across four screens and the Workers behind them. The names and portraits are sample profiles, and the larger pictures are scene images that the site and the apps use.&quot;)

Source: TEEI, captured from the website, the public tours of TEEI Language and TEEI Mentorship, and a live Amazon Chime SDK meeting with demo personas, 14 October 2026.

Oksana T. learned this way, in one-to-one sessions with volunteers on TEEI Language. Her film is one of those on the [stories wall](/about/stories/), and it plays from the same account as the rest of this note: the file sits in an R2 bucket and reaches the browser through the bucket&apos;s public domain.

![Oksana T., a TEEI Language graduate, in a square film. She talks about joining the programme when she felt stuck, becoming less afraid to speak, and the volunteers from all over the world who gave their time.](https://r2.theeducationalequalityinstitute.org/videos/cockpit/square/oksana-t-1.mp4?v=2026-09-22b#square &quot;Oksana T., who learned English on TEEI Language, on what the sessions gave her. 1 min 21 s, with captions.&quot;)

Source: TEEI film library, with Oksana T.&apos;s agreement to its use on TEEI&apos;s pages; this cut was rendered on 22 September 2026.

&amp;gt; &quot;I saw people all over the world who are so open-hearted... devoting their time.&quot;

&lt;cite&gt;Oksana T.&lt;/cite&gt;, TEEI Language graduate.

On [Skills Academy](https://skills.theeducationalequalityinstitute.org/), volunteers teach live workshops, clinics, expert Q&amp;amp;As, webinars and classes. Participants can combine language practice, skills learning and mentoring according to their needs, without following a fixed sequence. People earn certificates and badges as they go, drawn by a renderer on AWS Lambda and stored in R2. And when a volunteer gives their time through an employer, the company&apos;s managers see it in Evenhall.

The same infrastructure also serves TEEI Network Partners. NGOs, universities, churches and other community organisations bring their learners, members, staff and volunteers into TEEI&apos;s free programmes. They use their own Network Workspace to share TEEI Language, TEEI Mentorship and Skills Academy, contribute teachers and mentors, and follow participation and verified activity. We do not build a separate platform for each organisation. Their workspace reads participation from the same records as the programmes, using the same infrastructure and reporting systems. The applications remain within our Cloudflare environment, with the same protection through Project Galileo that covers the rest of TEEI&apos;s platforms.

The screens come from the public tours of [TEEI Language](https://language.theeducationalequalityinstitute.org/tour/learner) and [TEEI Mentorship](https://mentorship.theeducationalequalityinstitute.org/tour/mentee), from [Evenhall&apos;s demo workspace](https://evenhall.theeducationalequalityinstitute.org/demo/) and from a practice room with demo personas. Every name in them belongs to a sample profile, and none of the faces or pictures shows a participant.

## At a glance

Table: TEEI on Cloudflare, measured on 14 October 2026. Requests and runtime errors are estimates from Cloudflare&apos;s sampled analytics.

| Measure | Figure | Note |
|---|---|---|
| Traffic, 30 days | | |
| Requests to production Workers | 5.44 million | 14 September to 14 October |
| Requests that ended in a runtime error | 0.002 % | About 120 in 30 days, the last of them on 29 September |
| Storage and state | | |
| R2 buckets | 31 | |
| Stored in R2 | 18 GB | 381,023 objects |
| Durable Object namespaces | 19 | |
| KV namespaces | 17 | |
| D1 databases | 4 | |
| Account | | |
| Workers | 43 | Five are the core application Workers; other production Workers handle redirects, scheduling, monitoring and smaller services |
| Domains | 4 | Moved onto the account on 28 and 29 June |
| Queues behind Evenhall | 12 | Dead-letter queues and staging included |
| Delivery | | |
| Production deploys of TEEI Language | 235 | Since 27 June, with one automatic rollback on 22 July, triggered by a probe problem |

The requests are not evenly spread. Most of them reach the website, while the programme platforms do more work for each request they answer: TEEI Language made about 7.7 million calls to other services to answer its 1.39 million requests.

Table: Requests to TEEI&apos;s production Workers, 14 September to 14 October 2026. The figures come from Cloudflare&apos;s sampled analytics, so each one is an estimate.

| Platform | Requests | Share of total | Runtime errors |
|---|---|---|---|
| Website | 3.11 million | 57.2 % | 0 |
| TEEI Language | 1.39 million | 25.5 % | 50 |
| Evenhall | 304,000 | 5.6 % | 20 |
| TEEI Mentorship and Skills Academy | 219,000 | 4.0 % | 0 |
| Admin | 112,000 | 2.1 % | 0 |
| Everything else in production | 302,000 | 5.6 % | 50 |
| Total | 5.44 million | | 120 |

The last line holds the redirects for our other domains, the schedulers, the monitors, a tail Worker and the smaller portals. Its 50 or so runtime errors all came from Evenhall&apos;s scheduler Worker between 14 and 17 September. Because [Cloudflare&apos;s analytics sample requests](https://developers.cloudflare.com/analytics/graphql-api/sampling/), the errors arrive in steps of ten. Clients that disconnect before a response is finished are recorded separately and are not counted as errors here. A runtime error is a Worker invocation that did not complete, such as an uncaught exception or an exceeded limit. A page our code answers with an HTTP 4xx or 5xx status, a request the firewall blocks before it reaches a Worker, and whether a service was available are different measurements, so this table is neither a count of HTTP errors nor an uptime figure.

Day by day, each Worker has its own rhythm, and the daily lines show things the totals hide.

![Five small line charts of requests per day to each of the five core application Workers, 14 September to 13 October 2026, each on its own scale. In Cloudflare&apos;s sampled analytics the website ran between about 75,000 and 148,000 requests a day, and TEEI Language jumped to about 186,000 on 19 September. Dots mark days on which a Worker recorded runtime errors.](/images/blog/teei-cloudflare-workers/requests-daily.svg#phone,fit &quot;Requests per day to each of the five core application Workers. Each panel has its own scale, with the busiest day printed at its point, and a dot marks a day on which the Worker recorded runtime errors.&quot;)

Source: TEEI, from Cloudflare&apos;s GraphQL Analytics API, Workers invocations by script and day, read on 14 October 2026 at 18:26 UTC. The analytics are sampled, so each value is an estimate.

The website&apos;s line follows the week. TEEI Language is almost flat at 25,000 to 56,000 requests a day, apart from 19 and 20 September, when about 306,000 requests arrived in two days. The daily median wall time on those two days was about 23 milliseconds, against 430 on 14 September, so at least half of those requests finished in a fraction of the time a page takes. TEEI Mentorship has stayed above 9,500 requests a day since 5 October, against 3,300 to 5,900 a day between 14 and 25 September.

## One account, five core application Workers

The parts are easier to see together.

![Diagram of one Cloudflare account. Five core application Workers stand in a row, each with the Cloudflare services it binds: the website with Static Assets, KV, R2 and two cron triggers; TEEI Language with a Durable Object, Logpush to R2, a Placement Hint and the cron schedules for both Language and Mentorship; TEEI Mentorship, which also serves Skills Academy on a second hostname, with a Durable Object of its own, Logpush to R2 and a Placement Hint; Evenhall with three queues, three dead-letter queues, KV and a scheduler Worker; and admin with a Browser Run binding, R2, Smart Placement and a Tail Worker. Service bindings connect TEEI Language to TEEI Mentorship and to Evenhall, and admin to Evenhall. Smaller Workers on the same account handle redirects for our other domains, the Women Buddy Programme portal and a health monitor. Outside the account sit the Turso database in Europe (Ireland), Amazon Chime SDK for video, Amazon Bedrock for language models, AWS Lambda, where Playwright and Chromium render credential artwork for R2, Resend and Amazon SES for email, Sentry for errors and Algolia for search.](/images/blog/teei-cloudflare-workers/platform-map.svg &quot;One account: five core application Workers, the Cloudflare services each one binds, and what stays outside.&quot;)

Source: TEEI, drawn from our Worker configurations and our Cloudflare account, 14 October 2026.

**The website** is an [Astro 7](https://astro.build/) site that runs as a Worker with [Static Assets](https://developers.cloudflare.com/workers/static-assets/). The Worker runs first for every request. We use Worker-first routing so our entry can apply request handling before the asset layer. The website binds [KV](https://developers.cloudflare.com/kv/) for rate-limit counters and [R2](https://developers.cloudflare.com/r2/) for videos, course certificates and the share cards for published sessions. The share cards have a bucket of their own. A separate renderer outside Cloudflare writes them with an [R2 API token](https://developers.cloudflare.com/r2/api/tokens/), which is scoped to buckets, not to prefixes within them. R2&apos;s [temporary credentials](https://developers.cloudflare.com/r2/api/s3/temporary-credentials/) can be narrowed to a prefix, but the renderer does not use them. We use separate buckets to keep the share-card renderer&apos;s write access separate from certificate storage, so a fault in the renderer cannot overwrite the certificates people put on LinkedIn. Two cron triggers run the website&apos;s scheduled jobs.

**[TEEI Language](https://language.theeducationalequalityinstitute.org/)** and **[TEEI Mentorship](https://mentorship.theeducationalequalityinstitute.org/)** are two products on one platform, built from one [React Router 8](https://reactrouter.com/) codebase and deployed as two Workers. Language is where people practise a language through real conversations; Mentorship connects mentees with professional mentors. Skills Academy is served from a separate hostname on the Mentorship Worker; sharing a Worker does not decide which programme a participant takes first. Each Worker has its own Durable Object namespace for live presence and runs next to the database through a Placement Hint. Both send their logs to R2 through Logpush, so the logs outlast the few days Workers Logs keeps.

![The Discover page of Skills Academy on a laptop. Tabs for workshops, clinics, expert Q&amp;amp;As, webinars and classes sit above the search filters. The featured session is a sample expert Q&amp;amp;A with a button to register, and the week&apos;s sample sessions follow below it.](/images/blog/teei-cloudflare-workers/skills-academy-discover.webp#full,phone,1040x650 &quot;Skills Academy&apos;s Discover page, served from a second hostname on the Mentorship Worker. The sessions and hosts are samples.&quot;)

Source: TEEI, Skills Academy&apos;s Discover page with sample sessions, captured for TEEI&apos;s programmes page, October 2026.

**[Evenhall](https://evenhall.theeducationalequalityinstitute.org/)** is where a partner company&apos;s managers invite employees into TEEI&apos;s programmes, see what they did there, and turn it into a report they can file. It is a React Router 8 Worker with three [Cloudflare Queues](https://developers.cloudflare.com/queues/) and a small scheduler Worker of its own.

![The Impact overview in Evenhall&apos;s demo workspace for a sample company, marked as demo mode with sample data. A sidebar lists the overview, analytics, people, volunteer time and impact stories. The main panel shows example figures for the year, 4,932.50 verified hours and 1,066 learners, and beside them 99% of the 5,000-hour annual target and the evidence on file.](/images/blog/teei-cloudflare-workers/evenhall-demo-impact-2026-10-23.webp#full,phone,1280x800 &quot;A company manager&apos;s view in Evenhall&apos;s demo workspace. The company and its figures are sample data.&quot;)

Source: TEEI, captured from Evenhall&apos;s public demo workspace with sample data, 23 October 2026.

**Admin** is where TEEI&apos;s staff run the programmes, and it binds the same R2 bucket the website reads credentials from. Programme credentials are rendered with Playwright and Chromium on AWS Lambda and stored in Cloudflare R2. Admin sends its trace events to a [Tail Worker](https://developers.cloudflare.com/workers/observability/logs/tail-workers/) and uses [Smart Placement](https://developers.cloudflare.com/workers/configuration/placement/).

![A sample TEEI Language certificate of achievement with the placeholder name Your name, for the Language Learner role, with a seal and placeholder certificate details.](/images/blog/teei-cloudflare-workers/certificate-sample.webp#document,1123x794 &quot;A sample TEEI Language certificate. The name and the certificate details are placeholders.&quot;)

Source: TEEI, the sample certificate shown on the website&apos;s pages for TEEI Language, October 2026.

By bytes, those certificates and badges were most of what R2 held on 14 October 2026, and the films came next. Ihor R.&apos;s, below, is one of them: 6.7 MB in the same square cut the stories wall plays, and Oksana T.&apos;s above is 9.0 MB. Both reach the browser through the bucket&apos;s public domain, in the byte ranges the player requests when it plays. The player is set to `preload=&quot;none&quot;`, which asks the browser not to fetch the video ahead of playback; its poster image loads with the page.

![Ihor R., a career fellow on TEEI Mentorship, in a square film. He talks about moving to a different country, the help he got with his LinkedIn profile, cover letter and CV, and wanting to work as a project manager.](https://r2.theeducationalequalityinstitute.org/videos/cockpit/square/ihor-r-1.mp4?v=2026-09-22b#square &quot;Ihor R., a career fellow on TEEI Mentorship, on what his mentors helped him with. 1 min 22 s, with captions.&quot;)

Source: TEEI film library, with Ihor R.&apos;s agreement to its use on TEEI&apos;s pages; this cut was rendered on 22 September 2026. File sizes measured on 15 October 2026.

&amp;gt; &quot;Moving to a different country comes with challenges, especially when it comes to international job market. They helped me to tailor my LinkedIn profile, cover letter and CV.&quot;

&lt;cite&gt;Ihor R.&lt;/cite&gt;, career fellow on TEEI Mentorship.

![Horizontal bar chart of R2 storage by bucket group on 14 October 2026: 18.04 GB in 31 buckets, of which certificates and badges hold 10.92 GB and films 5.58 GB. The platform logs hold most of the objects, 318,916 of 381,023.](/images/blog/teei-cloudflare-workers/r2-buckets.svg#phone,fit &quot;What fills R2. Certificates and badges hold most of the bytes; the platform logs hold most of the objects.&quot;)

Source: TEEI, from Cloudflare&apos;s GraphQL Analytics API, R2 storage per bucket, the latest sample in the 48 hours before 14 October 2026 at 13:36 UTC. Stored bytes, metadata included.

By object count the order flips: the platform logs hold more than eleven objects for every certificate or badge.

Around these five sit smaller Workers: one that sends our Norwegian domains and our .com domain to the main site, the [Women Buddy Programme](/programmes/education/women-buddy-programme/) portal, a health monitor, and Evenhall&apos;s scheduler.

Some things are deliberately not on Cloudflare. The database is [Turso](https://turso.tech/), a hosted libSQL database with its primary in AWS Europe (Ireland): our programmes need one relational database that decides what is true, and running the Workers next to it was part of the performance work described below. Video calls run on [Amazon Chime SDK](/blog/from-practice-room-to-company-report-teei-amazon-chime-sdk-en/), and their audio and video never pass through our Workers; the Workers decide who may join and when. Features built on language models run on Amazon Bedrock. Credential artwork is rendered on AWS Lambda before it is stored in R2. Email goes out through [Resend](https://resend.com/) and Amazon SES, errors go to [Sentry](https://sentry.io/), and search for help answers and job titles runs on [Algolia](/blog/how-teei-uses-algolia-to-find-what-people-meant-en/).

## Two days in June: moving everything to a new Cloudflare account

On 28 and 29 June 2026 we moved TEEI&apos;s Workers, domains and buckets to a new Cloudflare account, using the Cloudflare API, [Wrangler](https://developers.cloudflare.com/workers/wrangler/) and [rclone](https://rclone.org/).

![Timeline of the move in three panels. 28 June 2026: the new account, R2 copied with rclone and verified byte for byte, the first Workers deployed, Workers Paid turned on for the website, and teei.no moved first, with no downtime. 29 June: the other three domains became active between 07:56 and 08:35 UTC. The main domain needed a zone hold on the old account released first, went across with 38 DNS records after nine old hostnames were dropped, and became active about seven and a half minutes after its nameservers changed. The week after: on 3 July a video bucket that had never been created, and on 7 July a deploy tool that still pointed at the old account.](/images/blog/teei-cloudflare-workers/move-timeline.svg &quot;Two days for the move, and a week of loose ends. All four domains were on the new account by 08:35 UTC on 29 June.&quot;)

Source: TEEI, drawn from our migration log and the activation times of our domains, 14 October 2026.

The plan had one rule: deploy everything on the new account first, then move the nameservers, and let a domain go only when everything behind it already answered from the new side. We started with teei.no, which only redirects, because a domain whose only job is to send people elsewhere is the cheapest place to learn what breaks. Both accounts ran the same redirect Worker and the same routes while the nameserver change spread, so it moved with no downtime, and the same steps then ran for each domain in turn. The main domain needed one more step. It carried a [zone hold](https://developers.cloudflare.com/fundamentals/account/account-security/zone-holds/) on the old account, which we released before the domain could be added to the new one, and its DNS went across with 38 records after we dropped nine staging and retired hostnames. It became active on the new account about seven and a half minutes after its nameservers changed, and every subdomain answered from the new account. We had allowed for up to 30 minutes of downtime per site.

The data was the simple part. Twenty-two R2 buckets were copied with rclone and verified byte for byte, eight empty log and staging buckets were created again, and two buckets in Cloudflare&apos;s EU jurisdiction were copied through the EU endpoint. Of all the bindings in our configuration, only two kinds of identifier change from one account to another: the ID of a KV namespace and the ID of a D1 database. R2 buckets, Durable Objects and Vectorize indexes are bound by name. Vectorize indexes cannot be exported cleanly, so ours were created empty on the new account.

The traps were in the deploys. These are the ones we wrote down in the runbook as we met them:

1. **A new [workers.dev subdomain](https://developers.cloudflare.com/workers/configuration/routing/workers-dev/).** The new account needed its own workers.dev subdomain, registered once before the first deploy, and every Worker on it got a new workers.dev address.
2. **New IDs, edited on a copy.** The old account kept serving production until each domain moved, so the KV and D1 IDs were swapped in a copy of each configuration, never in the repository.
3. **Deploy what the build produced.** Our React Router apps are deployed from the configuration their build generates, not from the source configuration. Bundling the source directly fails on a virtual module that only the framework&apos;s build provides.
4. **[Logpush](https://developers.cloudflare.com/workers/observability/logs/logpush/) and Tail Workers.** Both were in our configuration and neither is available on the Free plan, which is where the new account started. Logpush answered with error 10023, so both were switched off for the first deploys.
5. **[Cron triggers](https://developers.cloudflare.com/workers/configuration/cron-triggers/) off until cutover.** Both accounts talked to the same database. If a Worker&apos;s schedule ran on both, every job would run twice.
6. **Empty buckets must exist.** A Worker that binds an R2 bucket cannot be deployed until the bucket exists, even if it is empty.
7. **Secrets that only lived in Cloudflare.** A Worker secret cannot be read back, so the few secrets that existed only on the old account had to be found in their original source or created again.
8. **Workers without source.** Six old Workers had no source in any repository. The API endpoint for a Worker&apos;s content refused us with error 10405, but the endpoint for the script itself returned the code, so we deployed five of them again and left the sixth behind.

We also planned to stay on the Free plan, and that held for less than a day. The website&apos;s Worker was 4.94 MiB compressed, more than the Free plan allowed at the time, so we turned on [Workers Paid](https://developers.cloudflare.com/workers/platform/pricing/) on 28 June. DNS went in through the API rather than through a zone file import, because a BIND import drops the proxied flag on each record and every record would have needed fixing by hand.

The move took two days; the loose ends took a week. On 3 July we found that a video bucket had never been created on the new account, and on 7 July a deploy tool&apos;s cache still pointed at the old account. The Tail Worker for Language had no source left to deploy from, so we replaced it with [Workers Logs](https://developers.cloudflare.com/workers/observability/logs/workers-logs/), Sentry and a health monitor Worker.

## From Pages to Workers

Two of our applications had left [Cloudflare Pages](https://developers.cloudflare.com/pages/) for Workers before the move. Evenhall started in May: a small proxy Worker took over the hostname and sent each path either to the old Pages project or to the new React Router Worker, and paths moved across one prefix at a time. Admin followed the same pattern and switched completely on 14 May 2026, when its CNAME moved from the Pages project to the Worker.

One detail made Evenhall&apos;s switch invisible to the people using it. Evenhall counts requests in KV to rate-limit them, and the new Worker was bound to the same KV namespace the Pages project had used, &quot;so counters keep their state across the cutover&quot;. A limit that someone had reached a minute before the switch was still reached a minute after it.

The website now runs as a Worker with Static Assets as well, and its old Pages project was deleted on 4 September 2026.

What [Cloudflare&apos;s cache](https://developers.cloudflare.com/cache/) does for the zone is easiest to see by day.

![Stacked area chart of requests per day to TEEI&apos;s main zone, 14 September to 13 October 2026, by cache status. In Cloudflare&apos;s sampled analytics about 7.7 million of 10.7 million requests were cache hits, and about 2.7 million carried the status none, bypass or updating.](/images/blog/teei-cloudflare-workers/cache-status-daily.svg#phone,fit &quot;Requests to TEEI&apos;s main zone by cache status. The zone carries the website and the platforms&apos; hostnames. A hit was answered from Cloudflare&apos;s cache, none means the response did not go through the cache, and dynamic is a kind of response Cloudflare does not cache by default.&quot;)

Source: TEEI, from Cloudflare&apos;s GraphQL Analytics API, HTTP requests to the zone by cache status and day, visitor requests only, read on 14 October 2026 at 18:26 UTC. The analytics are sampled, so each value is an estimate.

About 7.7 million of the 10.7 million requests in those 30 days, seven in ten, were answered from Cloudflare&apos;s cache. About 2.7 million carry the status none, bypass or updating, and the dynamic and miss layers together are under 300,000. The hit share moves with the traffic mix: from about half on 19 September, the day of the TEEI Language burst, to about 86 percent on 27 September.

## One codebase, two Workers, and calls between platforms

TEEI Language and TEEI Mentorship share one repository, one database and most of their code, but they are separate products with their own words, rules and hostnames. We deploy the same code twice, as two Workers. The environment is chosen when the code is built, not when it is deployed: our framework&apos;s build flattens the configuration and drops the named environments, so a build for staging and a build for production are different artefacts from the start.

Hostnames are not in the Worker configuration at all. The comment above where the routes would be explains why:

```jsonc
// Production custom domains are intentionally NOT declared here. Wrangler
// 4.107.0 reconciles every declared custom-domain list as replacement state.
// Keeping route/routes absent makes ordinary Worker deploys domain-neutral; the
// separately approval-gated Workers Domains API contract owns the exact
// hostname-to-service mapping instead.
&quot;workers_dev&quot;: false,
```

An ordinary deploy can therefore never add or remove a hostname. The mapping lives in one place, is changed through the [Workers Domains API](https://developers.cloudflare.com/api/resources/workers/subresources/domains/), and needs its own approval. The account has seven [Workers custom domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) attached this way, including the two hostnames on the Mentorship Worker.

The two Workers talk to each other, and to Evenhall, through [service bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/). Language owns every cron schedule on the platform; Mentorship has none. When a scheduled job needs to act inside Mentorship, Language calls the Mentorship Worker through a binding instead of its public hostname, and since 13 October Skills Academy&apos;s scheduled jobs reach Mentorship the same way. Evenhall exports two named entrypoints, one for activating a company&apos;s application and one for the admin&apos;s review of applications, and Language and admin each hold a binding to the one they need. A call between them never leaves our account and has no public URL for anyone else to find.

Every call a Worker makes while it answers, to the database, to another Worker through a binding, to KV or R2, or to a service outside Cloudflare, is counted as a subrequest, so the ratio says how many calls each Worker makes to answer a request.

![Horizontal bar chart of subrequests per request for the five core application Workers over the 30 days to 14 October 2026, in Cloudflare&apos;s sampled analytics: about 20 for admin, about 12 for Evenhall and about 1 for the website. A note beside each bar gives the Worker&apos;s request count.](/images/blog/teei-cloudflare-workers/subrequests-per-request.svg#phone,fit &quot;Subrequests for each request answered, 30 days to 14 October 2026. Admin makes the most calls for each request; the website the fewest.&quot;)

Source: TEEI, from Cloudflare&apos;s GraphQL Analytics API, Workers invocations grouped by script, read on 14 October 2026 at 13:35 UTC. The analytics are sampled, so each value is an estimate.

Admin makes about 20 calls for each request it answers and Evenhall about 12. Language makes between five and six, which is lower than the database trips per page quoted below because the ratio counts every request, including the ones for assets and the API, not only pages.

We learned the hard way that a plain `fetch()` between our own Workers is not the same thing. On 21 September a small public Worker that reads data from our website answered every call with &quot;temporarily unavailable&quot; on its first staging deploy, while the same URL answered 200 from anywhere else.

A Worker&apos;s global fetch to a hostname that another Worker on the same account serves through a zone route is refused with error 1042, unless the Worker has the `global_fetch_strictly_public` [compatibility flag](https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public). Without the flag, the request goes to the zone&apos;s origin, and our website has no origin behind its Worker.

We set the flag rather than adding a binding. With it, the request goes through the public front door and the same security settings as any visitor, so that Worker sees exactly what the public sees, which was the point of it. Evenhall&apos;s scheduler Worker met the same error from the other side, and there a service binding was the right answer: it only needs to reach Evenhall.

The rooms needed one more setting. Background blur and noise reduction in our Amazon Chime SDK rooms need `SharedArrayBuffer`, and the browser only provides that to a page that is [cross-origin isolated](https://developer.mozilla.org/en-US/docs/Web/API/Window/crossOriginIsolated). The Language Worker therefore adds two headers to the room pages, and only to those:

```ts
if (isVideoSurfacePath(pathname)) {
  cloned.headers.set(&apos;Cross-Origin-Opener-Policy&apos;, &apos;same-origin&apos;);
  cloned.headers.set(&apos;Cross-Origin-Embedder-Policy&apos;, &apos;credentialless&apos;);
}
```

`credentialless` rather than `require-corp` lets the room load images from other origins without each of them sending a header of its own. Keeping the headers to the room pages means no other page pays for the isolation.

## Live presence on a Durable Object

Some conversations do not wait for a booking. A volunteer can mark themselves available now, and a learner who sees them can start a session straight away. The volunteer&apos;s screen then rings, and the volunteer answers or declines. If nobody answers, the session must be cancelled and the learner told, even if the volunteer&apos;s browser has been closed.

That needs something that can hold a connection open and keep time after every browser has closed, which a request and a response cannot do. That is a [Durable Object](https://developers.cloudflare.com/durable-objects/). The head of the file says what it does, and also what it does not:

```ts
// LivePresence Durable Object: the real-time plane for snap (&quot;Available now&quot;).
//
// ONE instance per environment (idFromName(&apos;live&apos;)). It is a THIN notification
// hub: Turso remains the single source of truth for live windows and bookings;
// this DO only
// (a) holds the WebSockets of connected clients (hibernation API, so idle
//     sockets cost nothing),
// (b) fans out &quot;live set changed&quot; ticks to learners and &quot;ring&quot; takeovers to
//     volunteers,
// (c) tracks transient ring state (claimed -&amp;gt; answered | declined | timeout)
//     and enforces the ring timeout with a DO alarm: an unanswered ring cancels
//     the snap booking in Turso and tells the waiting learner, even if every
//     browser tab died.
//
// Failure posture: everything here is best-effort enhancement. If the DO is
// cold, broken, or unbound, the product degrades to what shipped before it
// existed (push + email + loader revalidation), never worse.
```

The booking is committed first. The Worker then notifies the Durable Object and waits at most about two seconds; if that fails, the booking stands.

![Sequence diagram with five columns: the learner&apos;s browser, the Language Worker, the Turso database, the LivePresence Durable Object and the volunteer&apos;s browser. The volunteer&apos;s browser holds a hibernating WebSocket to the Durable Object. The learner picks the volunteer; the Worker writes the booking to the database first, then notifies the Durable Object and waits at most about two seconds for it, whatever the answer. The Durable Object rings the volunteer&apos;s screen and sets an alarm for two and a half minutes. If the volunteer answers, the ring is settled and both join the room. If the alarm fires first, the Durable Object asks the database to settle the unanswered booking and tells the learner the authoritative outcome.](/images/blog/teei-cloudflare-workers/live-presence.svg &quot;A ring, from the learner&apos;s choice to the volunteer&apos;s screen. The database is written first, and the wait for the Durable Object is capped at about two seconds.&quot;)

Source: TEEI, drawn from the live presence service in TEEI Language, 14 October 2026.

Three choices carry the design. There is one instance per environment, so every connected person reaches the same object and there is nothing to route. The sockets use the [WebSocket Hibernation API](https://developers.cloudflare.com/durable-objects/best-practices/websockets/), so an idle connection does not keep the object awake, and the keep-alive ping is answered by the runtime through `setWebSocketAutoResponse` without waking the object at all. And the database stays the source of truth: the object sends change signals, and the page loads persisted outcomes from the database. Three kinds of state are kept apart. The booking in Turso is the durable record. Pending rings and their deadlines are kept in the object&apos;s own durable storage. The open sockets, and who is online at this moment, are transient.

Running one object for months showed us three mistakes of our own.

**A value that could only grow.** The same object keeps watch over sessions that may need a rescue, and for each watch it kept a list of the five-minute boundaries it had already checked. Retirement measured a watch&apos;s age from the newest boundary, and the newest boundary moved forward five minutes on every check, so on-time checks never let the watch grow old enough to retire. Production writes failed with `SQLITE_TOOBIG` on 6 August, including the alarm&apos;s writes. Removing old entries needed a write too. The fix measures age from a fixed anchor stamped once and caps the history when it is read, so a later write can store a smaller value. The errors had stopped before the fix shipped; the fix stops the value from growing.

**Lost rings.** Durable Objects run with an [input gate](https://blog.cloudflare.com/durable-objects-easy-fast-correct-choose-three/) that holds back new events while the object waits for its own storage; external I/O allows other events to interleave. Our alarm read the map of open rings, waited for the database and for the notice that tells the volunteer they missed the request, and wrote the whole map back. That notice is in the app and mirrored to Web Push, rather than email. A ring that arrived during that wait could be overwritten. We found this in code review and tests, not in a measured production booking. The fix has two parts: it reads the map fresh, and it applies each decision only to the generation of the ring it evaluated. We did not reach for `blockConcurrencyWhile`, because on the single object that serves everyone it would have blocked every socket message while the alarm waited. The test harness hid it: its fake storage handed back the live object, so the old test passed with the bug in place. The revised fake returns detached copies so the test can expose stale writes.

**Ghost sockets.** In a test run on AWS, with the object logging every socket it accepted and closed, each page load by a mentor opened two sockets. [React&apos;s StrictMode](https://react.dev/reference/react/StrictMode) starts every effect twice in development, and the first socket was dropped while it was still connecting. Calling `close()` on a connecting socket abandons it in the browser only, so the object never heard about it. Over the run it accepted eight sockets and saw four close, and it kept answering that the mentor was online for 19 seconds and more after the last real socket had closed. The page now lets such a socket finish its handshake and closes it then.

Two smaller lessons came from the same object. We changed the integration to keep personal identifiers out of logs. And when the database times out while a socket is being opened, the Worker now answers 503 instead of 500, so the browser knows to try again later instead of treating it as a bug.

Durable Objects also shaped how we test. When we set this up, [Preview URLs](https://developers.cloudflare.com/workers/configuration/previews/) were not generated for a Worker that implements a Durable Object, so every agent and developer who needed a live copy of the platform got a Worker of their own. They accumulated, so on 22 September we deleted the preview Workers that nobody was using any more.

The same day, Cloudflare released [Worker Previews](https://developers.cloudflare.com/workers/previews/), which give every Preview a Durable Object namespace of its own. They need Wrangler 4.135.0 or later, and we already run 4.144.0. We are looking at them now. One thing we will have to plan around: a service binding from a Preview calls the production deployment of the Worker it binds, so a Preview of Language would reach production Mentorship and Evenhall.

## Running the Worker next to the database

By September, TEEI Language was quick in Western Europe and slow almost everywhere else. Over the week before 21 September, we measured the Worker&apos;s own time on page requests and grouped it by the data centre where the request came in. Request-weighted regional averages of the data centres&apos; median Worker times were 247 milliseconds in Western Europe, 1,193 in the western United States, 1,656 in Japan and 2,723 in Oceania. The code was the same everywhere, but the mix of pages was not controlled. In the week before the release, a page averaged about 17 database trips, some concurrently; the critical path was the number of dependent stages. Summed database-fetch time divided by trips averaged about 123 milliseconds, including database work and queueing as well as distance. The dashboard learners open first made about 72 trips.

Distance was the part we could act on. A Worker runs in the data centre closest to the person by default, and our database has one location, in AWS Europe (Ireland). Every trip crossed that distance again, so the further away someone was, the more each trip could cost them.

We had tried [Smart Placement](https://developers.cloudflare.com/workers/configuration/placement/) on staging at the end of August. Smart Placement needs consistent traffic from several locations before it decides where to run a Worker, and our staging Worker carried about 175 requests in a week, nearly all through Oslo. The trial never reached a decision. We knew the database&apos;s region, so on 21 September we committed a [Placement Hint](https://developers.cloudflare.com/workers/configuration/placement/#configure-explicit-placement-hints). The commit did not move production traffic; the release that did went out on 22 September at 05:30 UTC for Language and 05:34 UTC for Mentorship. This is the configuration, with its comment from 21 September, written from figures that still counted API calls with pages:

```jsonc
// Targeted placement (2026-09-21). The only database is the Turso primary in
// aws-eu-west-1, and a page averaged 13 to 15 sequential-ish round trips to it
// (72 on /home) at ~123 ms each from the data centre that served the user. This
// runs the fetch handler next to that primary instead. Static assets are
// unaffected (served nearest the user, this Worker is assets-first), the
// Durable Object places itself, and scheduled() is not a fetch handler. Proof
// of placement is the `cf-placement` response header (remote-) and the
// per-request dbTotalDurationMs/dbRoundTrips in Workers Logs; rollback is
// removing this key.
&quot;placement&quot;: {
  &quot;mode&quot;: &quot;targeted&quot;,
  &quot;region&quot;: &quot;aws:eu-west-1&quot;
},
```

Only the fetch handler moves. Static assets are still served from the data centre nearest the person, the Durable Object places itself, and scheduled jobs are not affected. We checked the move on 14 October 2026: requests to Language from Oslo answered with `cf-placement: remote-LHR`, which means the Worker ran in London, and a `cf-ray` that ended in `OSL`, where the request came in. Rolling it back means deleting four lines. The `mode` line is optional; `region` on its own sets the same hint.

Before the change, a preview Worker let us measure what the hop itself costs: at most about 130 milliseconds for a request that has to travel from Oslo to London and back. That price is paid once per request, while the database trips it shortens are paid many times.

![Bar chart comparing TEEI Language pages in the seven days before the release on 22 September 2026 with the seven days to 14 October 2026, each week taken as a whole. The median page time went from 433 milliseconds to 177, the 90th percentile from 1,747 to 462, database round trips per page from 17.1 to 5.8, and the time per round trip from 123 to 55 milliseconds. Placement, batching, loader rewrites and indexes changed together.](/images/blog/teei-cloudflare-workers/placement-before-after.svg &quot;TEEI Language pages across a combined release, one week as a whole on each side. Code removed trips; the data do not isolate placement&apos;s share of the time change.&quot;)

Source: TEEI, from the Worker logs that Logpush delivers to R2, for successful page requests that read the database, timed inside the Worker; read on 24 October 2026 from a random sample of the log files, 11.5 percent of them for the week before and 12.1 percent for the week after.

The change went out on 22 September as part of a larger release, and the first 41 minutes looked remarkable: a median page of 95 milliseconds, against 433 the week before. That did not hold over whole days, and we do not quote it as the result. We measure the result over a whole week on each side, on the same kind of request: a successful page request that reads the database, timed inside the Worker. In the seven days to 14 October, across about 132,000 such requests on Language, the median was 177 milliseconds, against 433 in the seven days before the release. The 90th percentile was 462 milliseconds, against 1,747. For Mentorship, the median over the same week was 147 milliseconds.

Placement, loader changes and indexes shipped together. The before-and-after figures describe that combined release and the traffic in each measurement window; they do not isolate the effect of placement. The later window averaged 55 milliseconds per database trip, against 123; a shorter distance is a plausible reason for part of that, alongside changed SQL work and indexes. Code removed trips. A request-local read coalescer batches eligible SQL reads into fewer transport calls; it does not deduplicate identical statements. Loader rewrites and indexes reduce other database work, and the later window averaged 5.8 trips per page against 17.1. The page-time change is the combined result, with later code and a different traffic mix. On a Samsung phone, the response headers of the dashboard learners open first took 3.4 to 4.9 seconds to arrive before the release and 1.0 to 1.5 seconds after it, although the before was measured on Wi-Fi and the after over a mobile network, so that comparison is only indicative.

Cloudflare&apos;s own analytics show the same change from the outside, on a different measure: the wall time of every invocation, including API calls, assets and scheduled jobs, not only pages.

![Four small line charts of wall time per request per day, 14 September to 13 October 2026, each on its own scale, in Cloudflare&apos;s sampled analytics. TEEI Language median: 430 milliseconds on 14 September, 185 on 13 October. TEEI Mentorship median: 385 on 14 September, 131 on 13 October. The p90 panels show where the slowest tenth begins, a thin dashed CPU line stays under 50 milliseconds all month, and a dated line marks 21 September on every panel.](/images/blog/teei-cloudflare-workers/wall-time-daily.svg#phone,fit &quot;Wall time per request on the two platform Workers, all invocation types included, with the CPU median as a thin dashed line. The dated line marks 21 September, when the placement setting was committed; the production release followed on 22 September.&quot;)

Source: TEEI, from Cloudflare&apos;s GraphQL Analytics API, Workers invocation quantiles by script and day, read on 14 October 2026 at 18:26 UTC. The analytics are sampled, so each value is an estimate.

The medians here are lower than the page times above because they take in everything the Worker answers, and on 19 and 20 September, the two days of the burst in the daily chart near the top of this article, the TEEI Language median drops to about 23 milliseconds. The CPU line hardly moves all month and sits far under the wall line, which is consistent with time spent waiting on calls rather than computing. After the 22 September release the distance between wall time and CPU time narrows on both Workers, and that distance is what the placement change was made to close.

We considered enabling [Workers Cache](https://developers.cloudflare.com/workers/cache/) and decided against it for personalised pages. Without explicit `private` or `no-store` directives, enabling that cache could allow a personalised response to be reused. A contract test keeps the cache setting absent from the Language Worker configuration.

## Queues behind Evenhall

Evenhall sends email, takes in applications from companies and syncs employee lists from HR systems. None of that should keep the person who pressed the button waiting, so Evenhall uses three [Cloudflare Queues](https://developers.cloudflare.com/queues/): applications, email and HR system sync. Each has a [dead-letter queue](https://developers.cloudflare.com/queues/configuration/dead-letter-queues/).

![Flow diagram of Evenhall&apos;s email queue. A manager presses a button in Evenhall. The Evenhall Worker writes an outbox row to the database and publishes a message that references the row to a Cloudflare Queue. Before sending, the queue consumer reads the row, acquires a conditional claim and calls the email provider with an idempotency key. On exponential retry paths the delay doubles from one minute up to an hour; a message retried after every failed delivery reaches its dead-letter queue after eight retries. That consumer passes it to the email-family handler before acknowledging it. Applications and HR system sync have their own consumers. Separately, a sweep every five minutes alerts the team when a scheduled job goes stale.](/images/blog/teei-cloudflare-workers/evenhall-queues.svg &quot;Evenhall&apos;s email queue carries a reference to the outbox row, not the recipient or the message. Delivery state stays in the database; a successful queue acknowledgement does not prove delivery to a recipient.&quot;)

Source: TEEI, drawn from Evenhall&apos;s queue configuration and consumers, 14 October 2026.

This is where one of those messages starts. A manager pastes the employees&apos; email addresses and presses Send invitations. For each address the Worker saves an invitation and an outbox row in separate database writes, then publishes a queue message that references the row. If publication fails after both records have been written, the invitation and its outbox row remain saved. This is not the same atomicity guarantee as a transactional outbox written with the domain change, and recovery differs between message families.

![The Invite employees page in Evenhall&apos;s demo workspace for a sample company. A field takes email addresses, one per line or separated by commas, with a Send invitations button below it. A panel beside it explains how inviting works: add people, send invitations so that each person receives a single-use link, and track who has joined.](/images/blog/teei-cloudflare-workers/evenhall-demo-invite-2026-10-23.webp#full,phone,1280x800 &quot;Invite employees: for each address, an invitation and an outbox row in the database, and a reference to the row in the queue.&quot;)

Source: TEEI, captured from Evenhall&apos;s public demo workspace with sample data, 23 October 2026.

The queue settings show the retry and concurrency budgets. Evenhall had an earlier name when this code was written, and the queue names still carry it:

*Adapted for publication.*

```toml
# The message references the outbox row and carries envelope metadata.
# Recipient details and email content stay in the database.
# …

[[queues.consumers]]
queue = &quot;teei-csr-email-production&quot;
max_batch_size = 10
max_batch_timeout = 5
max_retries = 8
dead_letter_queue = &quot;teei-csr-email-dlq-production&quot;
max_concurrency = 2

[[queues.consumers]]
queue = &quot;teei-csr-email-dlq-production&quot;
max_batch_size = 10
max_batch_timeout = 5
max_retries = 3
max_concurrency = 1
```

Queue messages identify work held in the database rather than carrying the recipient list or message content. The email envelope holds the outbox row&apos;s ID, its kind, a version and optional trace identifiers, never an address, a name or a list of employees. The HR sync queue follows the same rule, and its comment says &quot;roster data never enters Queues&quot;. Before sending, the consumer reads the row and acquires a conditional claim. That keeps contact data out of the queue and keeps the database as the record of attempts, provider acceptance and delivery receipts. A row with a provider message ID or a final status is acknowledged and skipped. Consumers use guarded claims and provider idempotency where supported to handle retries. The claim admits one attempt while its lease lasts; a repeated attempt also needs the provider&apos;s idempotency key and an unchanged request within the provider&apos;s retention window. That reduces duplicate sends within those conditions; it is not exactly-once delivery. On exponential retry paths the delay doubles from one minute and stops growing at an hour. A message retried after every failed delivery reaches its dead-letter queue after eight retries, and its family handler runs before acknowledgement. The dead-letter consumer has a separate retry budget for its own failures. Recovery and terminal handling differ between message families. A final status on an outbox row, a message in a dead-letter queue, the provider accepting an email and the email reaching its recipient are four different things.

Two problems taught us more than the happy path did.

The first was a database client created once and shared between requests. On 9 September, in our browser test lane with several clients in parallel, requests hung and the runtime warned of a &quot;promise resolved from a different request context&quot;; our evidence documents no production incident. The fix that day moved the client cache into each request.

The second was silence. Evenhall&apos;s daily sync went stale on 12 July 2026, and nobody was told for two months. Now a sweep runs every five minutes and sends an alert when a scheduled job goes stale. It has been on in production since 28 September.

## The limits we hit

Workers have limits, and we have hit a fair number of them. Each one taught us something about our own code.

Table: Platform limits and rules TEEI has run into in 2026, in the order we met them. The paragraphs below give the detail.

| When | Limit | Where | What we changed |
|---|---|---|---|
| 25 Jul | 20,000 static assets (our own ceiling, Cloudflare&apos;s Free plan figure) | Website | 12,115 generated redirect pages are pruned before shipping, and a redirect rule on the zone answers those paths |
| 7 Aug | Worker size, compressed (Cloudflare&apos;s rule until 4 Sep) | TEEI Language | Our gzip check is an internal budget; the 15 October build was 4.19 MB, below our 4.25 MB ceiling |
| 15 Sep | A promise shared between requests | TEEI Language | Every caller gets its own 1.5-second budget |
| 21 Sep | Fetch to another Worker on the same account | Website | The `global_fetch_strictly_public` flag, or a service binding |
| 27 Sep | 128 variables and secrets per Worker | TEEI Language | A check that counts them before a deploy, flags moved into the database, and related secrets combined into one JSON secret |
| 3 Oct | Six connections waiting for a response | TEEI Language, scheduled jobs | Jobs start four at a time, and two at a time on the heaviest trigger |

**128 variables.** On Workers Paid, a Worker can carry [128 environment variables and secrets](https://developers.cloudflare.com/workers/platform/limits/#environment-variables) together. In September, Language carried 34 variables and 94 secrets, 128 in all, and adding one more secret was refused at the moment of the write, with error 10055: &quot;found a total of 129&quot;. We added a check that counts them before a deploy, moved feature flags into the database, and combined related secrets into one JSON secret. On 12 October the same limit refused two more writes to Language, and since then each change has made room before it added anything. On 15 October 2026 we counted 25 variables in the committed production configuration and 97 secret names live on Cloudflare, 122 against the limit of 128.

**Size.** For months, our size check compared the uncompressed bundle with a number someone had picked, and the number was raised 17 times. On 7 August we found that the limit Cloudflare applied then was the compressed size: 3 MB on the Free plan and 10 MB on Paid. Our August investigation concerned the compressed-size limits then in force. Cloudflare [removed those limits on 4 September 2026](https://developers.cloudflare.com/changelog/post/2026-09-04-increased-worker-size-limit/); the platform now checks a 64 MiB uncompressed bundle limit. Our smaller gzip ceiling remains an internal release budget. The comment on our check records that start-up time matters too, and that we had not measured ours. Language&apos;s measured build on 15 October 2026 was 4.19 MB compressed, below the 4.25 MB ceiling we set on 13 October when Spanish and Portuguese were added.

**Twenty thousand files.** Astro&apos;s fallback for other languages wrote one redirect page per language for every city page on the website, 12,115 small files in all, which took the build to 22,618 files. Cloudflare [documents](https://developers.cloudflare.com/workers/static-assets/#limits) 20,000 static asset files on the Free plan and 100,000 on Paid; we enforced our own 20,000-file ceiling for the website. Pruning 12,115 generated fallback pages brought that build under our release budget; the records do not establish that Cloudflare rejected it. Astro still generates those pages, and a step after the build removes them before it ships. In the July investigation, the rule returned a 301 in development but the production build returned a 404 for the affected paths. Our inference was that Astro&apos;s language routing answered before our middleware; we did not trace that ordering through the framework. A [dynamic redirect rule](https://developers.cloudflare.com/rules/url-forwarding/single-redirects/) on the zone runs before the Worker, so that is where those redirects live now.

**Six connections.** A Worker invocation can have [six connections waiting for a response](https://developers.cloudflare.com/workers/platform/limits/#simultaneous-open-connections) at once, and further calls wait for one of them. In August our scheduled ticks still started every job at the same moment, 22 of them within three seconds. On 12 August one quarter-hour tick read 8.2 million rows, and while it ran, database reads for real people timed out. Since then the scheduler starts jobs four at a time. That was not quite enough. On 3 October four quarter-hour ticks had database writes that aborted after 10 seconds without ever leaving the Worker, because the heaviest job and the smaller checks on the same trigger could still start in the same second. Since 11 October the trigger that carries that job runs two at a time.

**A shared promise.** Language used to hang now and then, for 80 seconds to almost 9 minutes, 31 to 44 times a day. Our diagnosis, reached by ruling out every other explanation rather than by catching it in the act, was a promise that was created once and shared between requests: when the request that created it was torn down, the promise could never settle for the others. The fix on 15 September gives every caller its own 1.5-second budget. From 8 to 12 October, Workers Logs show no hangs of that kind on Language or Mentorship.

## Deploy and rollback

We deploy often. Language has had 235 production deploys since 27 June 2026, and Mentorship 107. Every production deploy is written to a deploy log and probed. If the probe fails, the release script restores the previous version on its own, as long as that version still works with the database schema; when a migration has made the old version unsafe, we go forward with a fix instead.

That has happened twice, and both times the probe was the problem. On 22 July a Language deploy failed its probe at 07:10:12 UTC, because the probe expected a heartbeat the new version had only just added and that had not yet been seen; the previous version was back six seconds later, and a release with the corrected probe went out at 08:05 and passed. On 28 July a Mentorship deploy rolled back because the probe still expected the closed service Mentorship had been before it opened on 24 July.

Seen week by week, the deploys also carry the story of the limits above.

![Bar chart of production deploys of TEEI Language per week, from the week of 22 June to the week of 12 October 2026: 235 in all, 45 in the busiest week, none in the week of 3 August. The rollback on 22 July is marked on its week, and six numbered marks name the limits in the order we met them, from the 20,000-file ceiling we held ourselves to on 25 July to six connections waiting on 3 October.](/images/blog/teei-cloudflare-workers/deploys-weekly.svg#wide,phone,fit &quot;Production deploys of TEEI Language per week since 27 June 2026, with the Language rollback of 22 July, triggered by a probe problem, and the six limits and internal budgets from the section above marked on the week each was met.&quot;)

Source: TEEI, from the production deploy log of TEEI Language, counted per week from Monday, read on 14 October 2026 at 18:26 UTC. The dates of the limits are the commits in the section above.

The busiest week, with 45 deploys, about a fifth of the 235, began on 21 September, the day we committed the Placement Hint; it reached production the next morning. The week of 3 August had none.

Rolling back is one command because Workers [keep versions](https://developers.cloudflare.com/workers/configuration/versions-and-deployments/). In a rehearsal on a staging Worker on 2 July 2026, the command took 4.3 seconds to put the older version on all traffic, and 4.1 seconds to restore the newer one. That rehearsal does not time a production rollback today, which first checks the live schema, then reads the deployments back and probes. For an urgent fix there is a separate lane that never deploys whatever happens to be newest, and that stops on a failed probe and leaves the rollback decision to that same schema check: it uploads one version with `wrangler versions upload`, and deploys exactly that version by its ID with `wrangler versions deploy`. The approval for it is bound to the checksum of that artefact. A checksum identifies what was uploaded, not what production serves, so as with our other releases we then read the deployments of that Worker to check which version is serving traffic.

We keep logs long enough to answer a question asked next quarter, not only one asked this week, and Workers Logs keep a few days. So Language and Mentorship send their logs to R2 through [Logpush](https://developers.cloudflare.com/workers/observability/logs/logpush/), and the bucket held 318,916 log objects in the 14 October 2026 snapshot. Logpush needs a job on the account and `logpush: true` on each Worker. Turning on Workers Logs does neither, and that is the trap: with observability on and `logpush` off, the Logpush job exists, reports no error and delivers nothing from that Worker. The two settings now sit next to each other in the configuration, with a comment that says so.

## Project Galileo in practice

The zone for TEEI&apos;s main domain, which also carries the platforms&apos; hostnames, runs on Cloudflare&apos;s Business plan through [Project Galileo](https://www.cloudflare.com/galileo/). Most of what that plan does is invisible, so here is what it did in three days.

From 11 to 14 October 2026, Cloudflare&apos;s analytics count about 1.03 million requests across that zone from people and bots. The [Cloudflare Managed Ruleset](https://developers.cloudflare.com/waf/managed-rules/reference/cloudflare-managed-ruleset/) blocked about 14,000 of them, or 1.4 percent. More than 10,500 matched the rule for version control files such as `.git`. Most of the rest were requests for common configuration files, fake search engine crawlers, a known code injection in a JavaScript template library, and probes for WordPress and DotNetNuke. The site has not run WordPress since it moved to Astro. We turned on the Managed Ruleset on 22 September. Probes that tried script injection and SQL injection were answered 200 before and 403 after, while 38 legitimate requests we tested answered exactly as before.

We read what those requests asked for from the rules they matched.

![Horizontal bar chart of requests blocked by the Managed Ruleset across TEEI&apos;s main zone in the 72 hours to 14 October 2026, by rule, in Cloudflare&apos;s sampled analytics: about 14,000 in all, of which about 10,500 asked for version-control files like .git and about 1,400 for configuration and backup files.](/images/blog/teei-cloudflare-workers/managed-rules.svg#phone,fit &quot;What the Managed Ruleset blocked across the main zone in three days, by rule. The names are shortened from Cloudflare&apos;s own rule names.&quot;)

Source: TEEI, from Cloudflare&apos;s GraphQL Analytics API, firewall events by rule, 11 October 13:00 UTC to 14 October 13:00 UTC, read on 14 October 2026 at 13:36 UTC. The analytics are sampled, so each value is an estimate; the rule rows sum to 14,068 against an overall count of 14,071.

The same zone carries the redirects that keep old links alive. The website used to run on WordPress, and other sites still link to its old addresses, among them news sites and job boards in Ukraine. On 12 October we found 39 of those addresses that answered 404 and added them to a [Bulk Redirect](https://developers.cloudflare.com/rules/url-forwarding/bulk-redirects/) list, 78 entries with both slash forms. All 117 checks we ran answered 301 in one hop, kept the query string, and ended on a page that answered 200.

## What this means for TEEI&apos;s work

TEEI&apos;s programmes are for people who are often on a phone, on a mobile network, in a country far from our database. In the week before 21 September, the regional summary of Worker time was 2.7 seconds for Oceania, against a quarter of a second in Western Europe. Placement and fewer dependent reads address that repeated distance, but we have not measured that regional gap with a comparable after-window. Keeping the platforms on one account means a small team sees all of them in one place: one set of analytics, one set of logs and one view of every Worker.

The numbers in this article come from the same analytics, logs and configuration we use to run the platforms, and the reports companies prepare in [Evenhall](/partners/evenhall/) are built from data that passes through the same Workers.

## A counter readers can check

[The public statistics](https://language.theeducationalequalityinstitute.org/api/lc/public-stats) that the Language Worker publishes said, on 14 October 2026 at 13:38 UTC, that 12,517 sessions and 9,435.5 hours of conversation have taken place across TEEI Language and TEEI Mentorship, with members in 148 countries.

The website shows the same counter. Its [impact page](/about/impact/) pins each published figure, so that a citation stays true, and under the record it prints the counter as it stands now. The website&apos;s Worker asks the Language Worker for that number, so the reader&apos;s address never reaches the counter, and reuses each reading for 30 seconds. The capture below was taken two hours after the reading above, so its live line is a little higher.

![The figure record on the impact page of TEEI&apos;s website, on a phone. It shows 11,984 completed sessions across TEEI Language and TEEI Mentorship, recorded since 22 April 2022, with a monthly chart and three checks: read from the live counter on 18 September 2026, defined in the measurement register, and pinned so that a citation stays true. Below the record, a line reads the live counter: 12,525 sessions, 541 more than the pinned figure, read on 14 October at 15:44 UTC.](/images/blog/teei-cloudflare-workers/impact-page-counter.svg &quot;The website&apos;s impact page on a phone: the pinned record, and under it the live counter from the Language Worker.&quot;)

Source: TEEI, captured from the impact page on TEEI&apos;s website at phone width, 14 October 2026.

## Thank you, Cloudflare

Through Project Galileo, Cloudflare gives TEEI the Business plan on the zone for our main domain and the firewall rules that come with it. That support protects the website that people from Ukraine, Latin America and the Caribbean, and volunteers from many countries, open first. Thank you, Cloudflare.

## Verify this

- See where our Worker runs. From a terminal, send `curl -sI https://language.theeducationalequalityinstitute.org/`. The response carries `cf-placement`, which shows the Worker ran next to our database, and `cf-ray`, whose last three letters name the data centre that received your request.
- Read the live numbers. [The public statistics for TEEI Language and TEEI Mentorship](https://language.theeducationalequalityinstitute.org/api/lc/public-stats) are answered by the Language Worker as you open them.
- Compare them with the website. The [impact page](/about/impact/) pins each published figure and prints the live counter under it.
- If you are building with Cloudflare or with TEEI, [talk to us](/partners/technology/) and we will walk you through the code.

## Appendix: engineering notes

For developers who want the detail behind the sections above. The excerpts reflect the code reviewed for the 14 October 2026 account snapshot, with ticket numbers, identifiers and unrelated lines removed, and long comments rewrapped to fit the page. Technical corrections were checked against source again on 17 October.

### The Durable Object binding

The live presence object is declared once and migrated as a [SQLite-backed](https://developers.cloudflare.com/durable-objects/best-practices/access-durable-objects-storage/) class. Named environments do not inherit it, so the staging block repeats the binding.

```jsonc
&quot;durable_objects&quot;: {
  &quot;bindings&quot;: [{ &quot;name&quot;: &quot;LIVE_PRESENCE&quot;, &quot;class_name&quot;: &quot;LivePresence&quot; }]
},
&quot;migrations&quot;: [{ &quot;tag&quot;: &quot;v1&quot;, &quot;new_sqlite_classes&quot;: [&quot;LivePresence&quot;] }],
```

### Keep-alive without waking the object

The constructor tells the runtime to answer the browser&apos;s ping itself. A hibernating object is never woken just to say that it is still there.

```ts
class LivePresenceBase extends DurableObject {
  constructor(ctx: DurableObjectState, env: AppEnv) {
    super(ctx, env);
    // Keepalive without waking from hibernation: the runtime answers the ping
    // pair itself. Browsers use this to detect a dead socket and reconnect.
    this.ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair(LIVE_PING, LIVE_PONG));
  }
}
```

### Service bindings with named entrypoints

Language calls Mentorship and Evenhall without a public URL. Evenhall&apos;s binding names the class it exports, so Language can reach only that one entrypoint.

```jsonc
// Language is the only cron owner. Its narrowly authenticated scheduled-mentor
// release enters the Mentorship Worker through this direct binding, not the
// public hostname.
&quot;services&quot;: [
  {
    &quot;binding&quot;: &quot;MENTORSHIP_SCHEDULED_WAVE_SERVICE&quot;,
    &quot;service&quot;: &quot;teei-mentorship-rr7&quot;
  },
  {
    &quot;binding&quot;: &quot;CSR_APPLICATION_ACTIVATION&quot;,
    &quot;service&quot;: &quot;teei-csr-cockpit-rr7&quot;,
    &quot;entrypoint&quot;: &quot;CsrApplicationActivation&quot;
  },
  // …
],
```

### Logs that outlast the window

The comment is the lesson.

```jsonc
&quot;observability&quot;: {
  &quot;enabled&quot;: true
},

// Two DIFFERENT switches, and having only the first is the trap (2026-07-27).
// `observability` turns on Workers Logs, which is a rolling window of a few
// days that cannot be queried afterwards; `logpush` is what actually lets the
// Logpush job ship those trace events out to R2. With observability on and
// logpush off, the job exists, reports no error, and delivers nothing forever.
// …
&quot;logpush&quot;: true,
```

### Scheduled work from a queue

The scheduler starts jobs four at a time instead of all at once. The comment records the measurement that led to it.

```ts
/**
 * How many cron jobs may hold a Turso connection at the same instant.
 *
 * Every job used to be dispatched into its own waitUntil the moment the tick
 * fired, so all 22 of them started within three seconds and competed for one
 * primary in eu-west-1. Measured 2026-08-12: a single fifteen-minute tick read
 * 8,2 million rows, the baseline between ticks was ~200 000 per five minutes,
 * …
 *
 * The work is unchanged; only its shape is. Four at a time spreads the same
 * jobs across the window instead of stacking them into one burst, and the
 * tick&apos;s wall time is dominated by its slowest single job either way (digests,
 * 139 s), so nothing is delayed materially by the queue.
 */
const CRON_DISPATCH_CONCURRENCY = 4;
```

### A switch that does not travel with the code

During maintenance on the database, an operator&apos;s switch in KV stops the Language and Mentorship Workers from starting new writes. It is a KV key and not an environment variable for a reason that belongs to Workers. In the words of the code, &quot;a var or a secret travels with the Worker version&quot;, so a version prepared in advance would carry the switch as it stood when the version was made. A key in KV is read at the moment of the write, whichever version is running. The switch stops new work from starting, and it does not promise more than that: a change in KV can take a minute or more to reach every location.</content:encoded><language>en</language><lastModified>2026-10-24T00:00:00.000Z</lastModified><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Cloudflare Workers</category><category>Durable Objects</category><category>Cloudflare</category><category>Evenhall</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>How TEEI Works: One Platform, Many Programmes</title><link>https://theeducationalequalityinstitute.org/blog/how-teei-works-one-platform-many-programmes-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/how-teei-works-one-platform-many-programmes-en/</guid><description>How TEEI works: one platform of shared blocks. A new programme starts on parts that already work, and each is there because research shows what it does.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>*TEEI&apos;s programmes run on shared building blocks. A new programme starts on parts that already work, and each part is there because research shows what it does.*

TEEI runs its programmes on one platform. A learner practising English with a volunteer, a participant in a Skills Academy class, a mentee preparing for an interview and a company counting its employees&apos; hours all use the same building blocks: matching, live rooms, practice, feedback, booking and measured time. A new programme does not need a new system. It needs its own people, rules and goals, set up on blocks that already work.

TEEI is a nonprofit. Its free programmes connect people who are rebuilding their lives, today mainly people from Ukraine and women in Latin America and the Caribbean, with volunteers who offer connection, language practice, skills and guidance into work. Participants pay nothing, and neither do the companies whose employees volunteer.

This article explains how the model fits together in three layers: the building blocks the platform is made of, the research that shows why each block matters, and the programmes that run on them today. It ends with how a new programme starts, how we measure, and what we do not know yet.

**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.

## At a glance

- **Four doors, no ladder.** Connection, Language, Skills and Work have equal standing. A person starts where their need is, and no area is a requirement for another.
- **One platform, shared blocks.** Matching, live rooms, classes, practice, feedback, booking, a measurement register and Evenhall serve every programme.
- **A mechanism behind every block.** Each block exists because research shows what it does for learning, connection or work. No study has tested the model as a whole, and we say so.
- **Hours from rooms, not calendars.** On 15 October 2026 the public counter read 12,560 sessions and 9,462.9 hours of conversation across TEEI Language and TEEI Mentorship.
- **AI proposes, people decide.** Practice Companion, our AI practice space, opens to learners once it has passed L2SAFE, the benchmark TEEI Research built to test whether a model understands and answers a question as safely in learner language as in fluent language.
- **Built to grow.** New programmes start on the same blocks, and other NGOs will be able to run their own programmes on the platform, free, as a service.

## Four doors, no ladder

TEEI&apos;s offer has four areas of equal standing: Connection, Language, Skills and Work. Someone can come for a person to talk to, for language practice, for a skill or for guidance into work, and stay with that for as long as it helps. A stable buddy relationship is a good outcome without a course. A language class is useful without a career goal. A mentor can help an experienced professional move forward without sending them back to basic training.

The research explains why there is no ladder. In Denmark, rules that pushed refugees into work first brought quick, mostly precarious jobs, crowded out language learning and did little for women ([Arendt, 2022](https://doi.org/10.1007/s00148-020-00808-z)). Sequences that demand language first and everything else later fail in the other direction: people wait, and the skills they arrived with lose value. The programmes that worked ran language alongside practical work rather than before it ([Dahlberg et al., 2022](https://doi.org/10.48550/arXiv.2203.00487)). So the doors connect, but none is locked behind another.

The connections are offered, not imposed. A participant can bring a task from a Skills Academy class into a language conversation, a mentor can help choose what to learn next, and Practice Companion can give practice between classes. Many participants, especially women with caring responsibilities, have little time, so the design aims for one main activity and one supporting relationship at a time, with a clear next step.

## The building blocks

Everything participants see is built from a small set of shared parts. Each programme uses the ones it needs.

### Matching and relationships

People choose who they work with. A learner picks a conversation partner from the volunteers available in their language. A mentee browses approved mentors by field and experience. A woman in the Women Buddy Programme is shown her best matches on city, language, interests and availability, and picks one. The platform proposes; the person chooses, and a proposed match is never counted as a result. In TEEI Language, a volunteer can also mark themselves available now. A learner who sees them can start a conversation at once, the volunteer&apos;s screen rings, and if nobody answers within two and a half minutes the session is cancelled and the learner told, even if every browser has closed.

The research says why choice matters. In the one randomised study that let women pick who they learned with, the choice was the active ingredient ([Field et al., 2016](https://doi.org/10.1257/pol.20140215)). In the German buddy trial, fewer than one in five of those offered a buddy ever started, often because matching was slow or the language gap too wide ([Jaschke et al., 2022](https://doi.org/10.1080/1369183X.2022.2058918)). Fast matching, an easy start and the option to bring someone you know are design responses to that.

### Live rooms with measured time

Live conversations, classes and workshops take place in TEEI&apos;s own video rooms, built on Amazon Chime SDK and opened from the participant&apos;s TEEI page rather than a link to another app. Before joining, a person checks camera and microphone in a lobby that shows the start time in both cities and what the learner asked for when booking. In the room, the other person fills the screen; captions, chat, raised hands and reactions stay at the edges. Audio and video travel directly between browsers and the media service, never through TEEI&apos;s servers. For every joining, leaving and dropped connection, the room emits an event. Those events, settled after the session, are what become a verified hour.

![A one-to-one session in progress on a laptop, shown with demo personas in place of real participants. The learner fills the screen, the volunteer&apos;s own picture sits in the corner, the time left is shown at the top, and the controls for microphone, camera, screen sharing, raised hand, reactions, captions, chat and notes run along the bottom.](/images/blog/teei-amazon-chime-sdk/room-one-to-one.webp#full,phone,1280x800 &quot;A one-to-one session, with demo personas: the other person fills the screen, and everything else stays at the edges.&quot;)

Source: TEEI, captured in a live Amazon Chime SDK meeting, with demo personas in place of real participants, 18 October 2026.

The rooms are tuned for weak networks. When we measured 934 production sessions on 7 September 2026, packet loss was the problem, not latency: in one session in ten it reached about 21 percent or more. So the rooms send redundant audio, allow five minutes to reconnect, and keep the last video frame rather than showing a black box during a reconnect. The [engineering note on Amazon Chime SDK](/blog/from-practice-room-to-company-report-teei-amazon-chime-sdk-en/) gives every setting and why.

### Classes

Teacher-led and volunteer-led classes use the same rooms with roles. In a webinar, a listener receives audio and video without sending it, and a host can invite someone to speak and return them to listening. Workshops, clinics and classes are built for conversation, so everyone in them can speak. Instructors who apply on their own are approved before they teach, while a colleague a company sends creates and teaches classes and workshops on topics they choose. Participants enrol, and a class certificate follows confirmed attendance and reviewed work. TEEI Language Courses add materials and assignments in itslearning. In TEEI Admin, AI support for classes proposes which class a participant fits and helps follow them up, and a teacher or coordinator decides.

### Practice

Practice Companion is the engine for structured practice between conversations with people. One AWS Lambda function, behind a signed token, calls Claude models on Amazon Bedrock through EU cross-region inference, and an Amazon Bedrock guardrail judges what reaches the learner. Every model call returns a structured answer through a forced tool, a fixed answer format the model has to fill in, so a correction carries its category from a versioned list, and a correction has to point at the learner&apos;s exact words before it is stored as evidence. The same engine, at its own route, gives AI first feedback on Skills Academy work, and a career companion on its own function helps mentees prepare between mentor sessions, in at most 250 words, referring the mentee back to their mentor and never claiming a relationship of its own.

### Feedback and assessment

Every score the platform&apos;s AI gives must quote the person&apos;s own work, and a score whose quote cannot be found in the work is refused. The quote is checked twice, once in the function and again in the backend. A reading carries its provenance: the model, the prompt, the schema and the rubric, and for a level reading a fingerprint of the scorer, so each stored score names the judgement that produced it, and a changed scorer can never look like a changed person. In Skills Academy, AI gives the first feedback and a person on the class team reads it before the scores count; in TEEI Language, a level reading calibrates the companion and is never a grade.

### Booking and follow-up

Availability, booking, notifications, reminders and rebooking are built once and shared across the programmes, and each programme sets its own rules for them. A volunteer sets the hours they can give; a participant books the sessions, or in a course is placed in a class. Nobody has to recruit an audience, and nobody has to learn a second system when they move from one programme to another.

### The measurement register

Every outcome has a written definition before it has a value. Activity is counted from room events. Results are measured with named instruments on named dates. Effects need a comparison, and the register says which. A new measure starts as &quot;Reserved&quot;: the rule is written and the first record is still to come. That is a status, not a zero. The same definitions apply across programmes, and will apply across other organisations running on the platform, so that work can be compared and still reported separately.

### Company reporting: Evenhall

[Evenhall](/partners/evenhall/) is the free workspace for companies. Managers invite employees into TEEI Language, Skills Academy and TEEI Mentorship, where they take part in the same rooms as everyone else. Evenhall reads verified participation from the programme platforms overnight. For one-to-one sessions, an employee&apos;s hours are the time both people were connected, from sessions settled as completed; for an instructor, the measured time in the room. Where nothing was measured, nobody gets hours. A manager builds the report from those records, with every figure linked to its source, reviews it and shares it. The company sees counts and hours, never participants&apos; names, and never what was said.

### Two rules across all eight

AI checks, drafts and proposes, and people make the decisions that matter for a person. Five AI checks run for participants today: screening of volunteer applications, safeguarding in chat, a help assistant that answers only from TEEI&apos;s help articles, review of return plans, and a guide for group practice. In every case either the platform&apos;s written policy or a member of staff makes the final decision, and the checks record outcomes and counts, not the text they read.

Data stays with its purpose. A learner&apos;s practice history does not reach their volunteer. Health information never reaches a career profile. Company reports never contain participants&apos; names. And every person who joins a room is handed to the video service under a pseudonym that changes with every meeting.

![Nine programmes and workspaces set against the eight shared building blocks: matching, live rooms, classes, practice, feedback, booking, measurement and reporting. For each of TEEI Language, TEEI Language Courses, Practice Companion, Skills Academy, TEEI Mentorship, the Women Buddy Programme, One Connection, TEEI Meetings and Evenhall, the figure marks which blocks it uses, between one and five each. A filled cell means the programme uses that block.](/images/blog/how-teei-works/platform-blocks.svg#phone,fit &quot;TEEI&apos;s platform: nine programmes and workspaces on eight shared blocks.&quot;)

Source: TEEI, drawn from the platform and programme documents, 15 October 2026.

The programmes at the top bring their own people, roles and rules. Everything under them is shared, which is what lets a new programme start on parts that already work.

## The research behind the blocks

TEEI did not start from technology. It started from what research says makes adult learning, connection and the move into work succeed, and built a block for each mechanism. One rule runs through everything below: a research finding justifies a mechanism, a design choice is described as TEEI&apos;s choice, and a result from TEEI needs TEEI&apos;s own data.

### Interaction and feedback

Conversation is not a reward at the end of language learning. It is one of its engines. Across 28 studies, conversational interaction had a medium effect on immediate tests and a large effect on delayed tests (Mackey &amp;amp; Goo, 2007). Corrective feedback adds to it, with a medium effect across 33 studies that held over time ([Li, 2010](https://doi.org/10.1111/j.1467-9922.2010.00561.x)). Volunteers can deliver this kind of support online: fully online one-to-one tutoring by trained volunteers raised learning by about 0.2 standard deviations, and more with a higher dose ([Carlana &amp;amp; La Ferrara, 2025](https://doi.org/10.1257/aer.20240401)).

This is why every TEEI Language learner practises with people, not only with software, and why a correction in Practice Companion is anchored in the learner&apos;s own sentence.

### Spaced practice, and AI with a teacher&apos;s discipline

Practice spread over days and weeks beats the same practice crammed together, across 48 experiments with 3,411 learners ([Kim &amp;amp; Webb, 2022](https://doi.org/10.1111/lang.12479)). AI makes spaced practice possible at any hour, but the evidence also sets limits. In a field experiment with about a thousand secondary school students, open access to GPT-4 raised scores while students had it and left them 17% worse off once it was taken away. A tutor version that gave hints instead of answers largely removed the harm ([Bastani et al., 2025](https://doi.org/10.1073/pnas.2422633122)). And when chatbots are compared with other learning technology rather than with nothing, their advantage shrinks to a small one ([Lyu et al., 2025](https://doi.org/10.1111/ijal.12668)).

Practice Companion is built on those findings. It asks the learner to produce language, gives one thing back at a time, and checks later whether a form has become theirs without help.

### Belonging and small groups

Relationships can turn learning into action. In a randomised study of 636 women in India, business training changed women&apos;s business activity only when they were invited to attend with a friend, and the effect was strongest where social norms limited women&apos;s freedom of movement ([Field et al., 2016](https://doi.org/10.1257/pol.20140215)). Belonging matters too, but only where there is something real to belong to. In a trial with 26,911 students at 22 institutions, a short exercise on belonging raised first-year completion by one to two percentage points, and only in settings that offered real chances to belong ([Walton et al., 2023](https://doi.org/10.1126/science.ade4420)). Small groups with a shared task do more than open channels: women placed in all-female virtual groups on a career platform were about seven percentage points more likely to finish their training on time ([Melin, Yang &amp;amp; Goulas, 2026](https://doi.org/10.1287/orsc.2025.20602)).

Connection is also a goal in its own right. Four weeks of empathy-focused phone calls from trained laypeople reduced loneliness on the UCLA loneliness scale, though not on a second measure ([Kahlon et al., 2021](https://doi.org/10.1001/jamapsychiatry.2021.0113)). A review of 280 studies found that loneliness interventions help in the short term, with low certainty and wide differences between types of intervention ([Lasgaard et al., 2026](https://doi.org/10.1037/amp0001578)). A German trial of volunteer buddies for refugees found more contact with locals and higher satisfaction with housing, but no effect on employment ([Jaschke et al., 2022](https://doi.org/10.1080/1369183X.2022.2058918)).

TEEI therefore treats connection as an outcome, not as a funnel into employment, and builds a separate bridge to work.

### Mentoring and weak ties

Professional mentoring helps when it is aimed at work. In a randomised evaluation in Poland, women moving into technology who were mentored by senior women in the field were 15 percentage points more likely to be working in tech a year later ([Athey &amp;amp; Palikot, 2026](https://www.nber.org/papers/w34750)). Experiments with LinkedIn&apos;s connection recommendations, covering 20 million users, found that moderately weak ties produce the most job mobility, especially in digital industries ([Rajkumar et al., 2022](https://doi.org/10.1126/science.abl4476)).

TEEI Mentorship therefore lets mentees choose mentors by field and experience, and keeps mentoring separate from buddy relationships.

### Proof of skill

People are hired on what employers can see. In Ethiopia, a workshop that certified young job seekers&apos; skills raised their earnings by 25% four years later, while a transport subsidy that only made searching cheaper had no lasting effect ([Abebe et al., 2021](https://doi.org/10.1093/restud/rdaa057)). In Poland, an online portfolio programme costing about 15 US dollars per participant raised the share working in tech by 11 percentage points ([Athey &amp;amp; Palikot, 2026](https://www.nber.org/papers/w34750)). Learning research points the same way: practice activities are associated with about six times the learning gains of reading or watching ([Koedinger et al., 2015](https://doi.org/10.1145/2724660.2724681)), and feedback that explains the task and the process outperforms praise alone ([Wisniewski, Zierer &amp;amp; Hattie, 2020](https://doi.org/10.3389/fpsyg.2019.03087)).

Skills Academy therefore ends in work a person has done and someone has reviewed, not only in a certificate.

### Why the connections matter

The strongest evidence is for programmes that combine parts. In Gothenburg, 140 low-educated refugees were randomised to a programme of three months of intensive Swedish, six months of supervised work practice with continued language classes, and job-search support from caseworkers. About 30% of the treatment group were employed each month in the year after the programme, against about 15% of controls, at a cost of around SEK 27,000 per participant. Better language skills explained only a small share of that effect; most of it came through practice, supervisors and matching ([Dahlberg et al., 2022](https://doi.org/10.48550/arXiv.2203.00487)). I-BEST in Washington State teaches basic skills and a trade together, with a dedicated adviser. In a randomised evaluation it raised college credits and short-term credentials for adults with limited basic skills, though effects on employment and earnings after six years were mostly not detectable ([Martinson &amp;amp; Glosser, 2022](https://www.abtglobal.com/files/insights/reports/2022/i-best-6-year-report-mar-2022.pdf)). Year Up, which combines training, support services and employer connections, raised earnings by 30% in the seventh year after random assignment ([Fein &amp;amp; Dastrup, 2022](https://acf.gov/opre/report/benefits-last-long-term-impact-and-cost-benefit-findings-year-up)).

Those studies test whole packages. They cannot say which parts were necessary. A few studies isolate one connection, and they point the same way: a social component made training effective for women ([Field et al., 2016](https://doi.org/10.1257/pol.20140215)), intensive human case management raised completion where emergency financial aid alone did not ([Evans et al., 2020](https://doi.org/10.1002/pam.22256)), and signalling skills to employers raised earnings where cheaper searching did not ([Abebe et al., 2021](https://doi.org/10.1093/restud/rdaa057)). Across 207 studies of active labour-market programmes, average effects are close to zero in the short run and turn positive two to three years after a programme ends, most for programmes that build skills ([Card, Kluve &amp;amp; Weber, 2018](https://doi.org/10.1093/jeea/jvx028)). A model like TEEI&apos;s should not be judged at three months.

Three connections have the strongest support, and they are the ones TEEI builds first.

![Four areas drawn as equal boxes, Connection, Language, Skills and Work, with three highlighted links between them. Connection to learning: a friend makes training stick and a person keeps you going, from Field 2016 and Evans 2020. Language alongside Work: language with work, not before it, from Arendt 2022 and Dahlberg et al. Skills to Work: proof of skill and introductions, not cheaper searching, from Abebe 2021 and Athey and Palikot. A note says that the other links, language to skills, belonging, AI practice and buddy to work, have thinner or conditional support, and that no study has tested the four areas together.](/images/blog/how-teei-works/three-connections.svg#phone,fit &quot;The three connections: four areas, three links with causal support.&quot;)

Source: TEEI, drawn from the studies named in this article, 15 October 2026.

### The evidence, connection by connection

Table: The evidence, connection by connection. Each row names the connection, the kind of evidence behind it, and what the studies do and do not show.

| Connection | Evidence | What we know, and do not |
| --- | --- | --- |
| Connection to sustained learning | Causal, one component isolated ([Field 2016](https://doi.org/10.1257/pol.20140215); [Evans 2020](https://doi.org/10.1002/pam.22256)) | Strongest for women under restrictive norms. Not tested for a digital, cross-border buddy. |
| Connection to belonging and loneliness | Causal, limited ([Jaschke 2022](https://doi.org/10.1080/1369183X.2022.2058918); [Kahlon 2021](https://doi.org/10.1001/jamapsychiatry.2021.0113)) | Buddies raise contact and satisfaction. Loneliness falls on some measures, not all. |
| Connection directly to work | Causal support for no average effect ([Jaschke 2022](https://doi.org/10.1080/1369183X.2022.2058918)) | A buddy does not bring a job. Work needs its own bridge. |
| Language to skills | Causal at package level (I-BEST) | Teaching language and basic skills through a trade opens study. Its separate value is untested. |
| Language to work and job quality | Quasi-experimental ([Foged 2024](https://doi.org/10.1162/rest_a_01216); [Clausen 2009](https://doi.org/10.1016/j.labeco.2008.12.006)) | Strong where the host language is the working language. Thin for English as a second language in Latin America. |
| Early work against language | Causal interaction ([Arendt 2022](https://doi.org/10.1007/s00148-020-00808-z); [Arendt &amp;amp; Bolvig 2023](https://doi.org/10.1016/j.econedurev.2022.102353)) | Work-first crowds out language. Short-term gain, long-term cost, little effect for women. |
| Mentoring to work | Causal at package level, plus subgroups ([Athey &amp;amp; Palikot 2026](https://www.nber.org/papers/w34750); [Battisti 2019](https://doi.org/10.1016/j.labeco.2019.07.001)) | Works when professional and aimed at work, most for those without networks. |
| AI practice to language | Causal, conditional ([Bastani 2025](https://doi.org/10.1073/pnas.2422633122); [Lyu 2025](https://doi.org/10.1111/ijal.12668)) | Works with structure and a teacher&apos;s discipline. Without structure it can weaken learning. |
| Volunteer one-to-one online to learning | Causal ([Carlana &amp;amp; La Ferrara 2025](https://doi.org/10.1257/aer.20240401)) | Works with training and dose. |
| Career goals to motivation for language | Mostly theoretical | Not tested directly. |

No study has tested a model like TEEI&apos;s: four areas of equal standing, delivered digitally, across borders and largely by volunteers. The case for it rests on evidence about each mechanism. It is a reasoned design, and we treat it as one.

## The programmes running today

Each programme takes the blocks it needs and adds what is its own: who it is for, the roles, the words, the rules and the safeguarding.

### TEEI Language: conversation with people

[TEEI Language](/programmes/education/teei-language/) is conversation with people. A learner books a volunteer conversation partner for 30, 45 or 60 minutes and talks, one to one, on video. There is no lesson plan, no grading, no teaching qualification and no native-speaker requirement. Volunteers need to be comfortable in the language they offer, and TEEI offers twelve, including English, Spanish, Ukrainian, Polish and Norwegian. Learners choose the volunteer and the time. Volunteers who like groups host Speaking Clubs, where a conversation card gives the group something to talk about and the host can invite someone to speak or settle the room.

**A learner&apos;s week.** Picture Olena, 38, a Ukrainian accountant in Poland, who wants to work in her field again. She books a Polish-speaking volunteer twice a week and asks, at booking, to practise talking about her work. In the lobby she sees the start time in her city and the volunteer&apos;s and checks her camera. In the session, she asks for live captions, which start once the volunteer agrees, so she can see how a word she just heard is spelt. After the call, she saves the transcript to her own account. TEEI keeps no copy otherwise.

![The lobby before a one-to-one session in TEEI Language, shown with demo personas in place of real participants. On the left, the start time in Oslo and in Kyiv, and the learner&apos;s words from booking: I would like to practise talking about my work. On the right, the volunteer&apos;s camera preview, a button to join, and the camera, microphone and background blur settings.](/images/blog/teei-amazon-chime-sdk/room-lobby.webp#full,phone,1280x800 &quot;The lobby, with demo personas: the time in both cities, what the learner asked for when booking, and a camera and microphone check before joining.&quot;)

Source: TEEI, captured in a live Amazon Chime SDK meeting, with demo personas in place of real participants, 18 October 2026.

**Why it is built this way.** Conversational interaction had a medium effect on immediate tests and a large effect on delayed tests across 28 studies, which suggests it sets off learning that continues after the session (Mackey &amp;amp; Goo, 2007). Fully online one-to-one support from trained volunteers works, with a higher dose working better ([Carlana &amp;amp; La Ferrara, 2025](https://doi.org/10.1257/aer.20240401)). A fixed partner builds trust and negotiates meaning more freely (Plough &amp;amp; Gass, 1993), while new partners force more explicit communication, so learners can keep a regular partner and still meet new people. Language anxiety is consistently linked to lower performance (r = -0.36; [Teimouri et al., 2019](https://doi.org/10.1017/S0272263118000311)), which is why the session has no grades and no assessment.

**Built for everyday life in Ukraine.** For many participants, a power cut or a phone switching networks mid-sentence is part of an ordinary day. The rooms use an audio profile with redundancy, so a lesson survives losing the picture but not the voice, and they allow five minutes to reconnect instead of the default two, so a conversation interrupted by a power cut continues when the person returns. Background noise is reduced in the browser, and background blur lets someone take part without showing their home.

**What is measured.** Time both people were connected, settled after the session. Not attention, and not learning. Those need the measures below.

### TEEI Language Courses: structured classes

TEEI Language Courses are teacher-led online group classes, two to three sessions a week, with materials and assignments in itslearning. They run today. The teacher owns what the research says a teacher should own: progression, diagnosis, explicit explanation of form and assessment. Explicit instruction has a clear advantage for both simple and complex structures ([Norris &amp;amp; Ortega, 2000](https://doi.org/10.1111/0023-8333.00136); [Spada &amp;amp; Tomita, 2010](https://doi.org/10.1111/j.1467-9922.2010.00562.x)), and oral corrective feedback works in classrooms, not only in laboratories ([Lyster &amp;amp; Saito, 2010](https://doi.org/10.1017/S0272263109990520)).

The classes and the conversations work on the same goals without sharing the learner&apos;s private history. A learner can bring a topic from class into a conversation with a volunteer, and Practice Companion practises what came up in class.

In TEEI Admin, AI support for the classes proposes which class a participant fits, based on level, goal, language and availability, and helps follow participants up along the way. A teacher or coordinator confirms the placement, and a participant can ask to change class without giving a reason.

### Practice Companion: structured practice between conversations

Practice Companion is the part of TEEI Language a learner uses on the days between conversations with people. It has four rooms around one desk. In **Write**, the learner writes a sentence about their own life and gets one thing back. In **Listen**, they hear short everyday dialogues, written and reviewed by people, recorded once with synthetic voices, with the words hidden until wanted, and answer three questions that are never scored. In **Say it**, they talk a situation through by voice: a receptionist with two appointment times, a job interview, a new colleague on the first day. In the **level conversation**, they answer six written tasks and are read, not taught.

The rooms share one memory: the learner&apos;s level, the situation they are practising for, and a shelf of lines they have chosen to keep. A kept line can travel into the next booked session, and that line is the only thing from the companion the volunteer sees. What the companion knows about a learner stays with the learner.

**One correction, four chances.** After a correction, the companion looks for the corrected form in the learner&apos;s own later sentences in four windows: the same session, and around a day, a week and a month later. When a window is due, the conversation steers towards a moment where the form comes up naturally, at most once a session and never as a drill. This follows the evidence that spaced practice beats massed practice across 48 experiments ([Kim &amp;amp; Webb, 2022](https://doi.org/10.1111/lang.12479)), and the evidence that AI without pedagogical structure can produce better answers now and weaker learning later ([Bastani et al., 2025](https://doi.org/10.1073/pnas.2422633122)). A better sentence written with help is not the measure. The same form, a week later, in the learner&apos;s own words, is.

**Reading a level without inflating it.** The level conversation is six written tasks from a versioned plan. Code chooses the tasks; the model only words each question. A scorer reads the whole conversation three times, must quote the learner&apos;s own words for every criterion, and keeps the lowest band when the readings disagree. Progress between two conversations is a word, up, same or down, and only when both used the same instrument and different questions, so a new model can never look like progress. The level sets how the companion writes to the learner. It is never a requirement for a room, never a right to anything and never a certificate.

**Not open yet.** Practice Companion opens to learners once it has passed L2SAFE, the benchmark TEEI Research built to test whether a model understands and answers a question as safely in learner language as in fluent language. We are also tuning prompts and model settings so that learners get the best possible experience, and finishing the production setup of the voice path. The engineering note [Inside Practice Companion](/blog/inside-practice-companion-rooms-evidence-and-level-en/) describes every room, the certification results and the architecture.

### Skills Academy: skills shown in work, not in certificates

[Skills Academy](/programmes/education/skills-academy/) is where volunteers with professional skills teach live: one-off workshops, clinics, expert Q&amp;amp;A sessions and webinars, and classes that meet weekly for two to six sessions. Teaching is open to any employee of a partner company, in any field: HR, finance, law, marketing, leadership, product, data, software. A company can teach the skills behind its own tools, as long as the session page says whose technology it is, it teaches the skill rather than the sale, and participants&apos; details never go to the company. Alongside the live sessions, participants study at their own pace on DataCamp and take Google Career Certificates on Coursera, where those platforms issue their own certificates.

![The Discover page of Skills Academy on a laptop. Tabs for workshops, clinics, expert Q&amp;amp;As, webinars and classes sit above the search filters. The featured session is a sample expert Q&amp;amp;A with a button to register, and the week&apos;s sample sessions follow below it.](/images/blog/teei-cloudflare-workers/skills-academy-discover.webp#full,phone,1040x650 &quot;Skills Academy&apos;s Discover page. The sessions and hosts are samples.&quot;)

Source: TEEI, Skills Academy&apos;s Discover page with sample sessions, captured for TEEI&apos;s programmes page, October 2026.

**A participant&apos;s class.** Picture Mariela, 29, who sells in informal trade in Lima and wants to reach customers online. She has a smartphone but little experience with other digital tools, so she starts with a short class on spreadsheets and basic data, planned within four hours a week in the evenings. Each week ends in a task on real, anonymised data rather than in a video. She scores her own work against the rubric&apos;s three criteria before a reviewer sees it. In a clinic, an instructor looks at her spreadsheet and shows her where an AI-suggested formula went wrong and why. Five weeks in, she solves a new dataset without AI in 45 minutes. The class certificate follows when the teaching team confirms her attendance and reviewed work. Her work sample, not the certificate, is what she shows an employer.

**Why it is built this way.** Open online courses show what content alone does: 52% of those who registered for MIT and Harvard courses on edX never entered the material, and completion barely moved in six years ([Reich &amp;amp; Ruipérez-Valiente, 2019](https://doi.org/10.1126/science.aav7958)). What turns content into skill is around it. Doing practice activities is associated with about six times the learning gains of reading or watching ([Koedinger et al., 2015](https://doi.org/10.1145/2724660.2724681)). Feedback has a medium effect overall (d = 0.48); feedback that explains the task and the process does far more (d = 0.99), while praise and punishment alone do the least (d = 0.24) ([Wisniewski, Zierer &amp;amp; Hattie, 2020](https://doi.org/10.3389/fpsyg.2019.03087)). Having peers assess work improves performance (g = 0.31), also online ([Double, McGrane &amp;amp; Hopfenbeck, 2020](https://doi.org/10.1007/s10648-019-09510-3)). Support should fall away as competence grows, since help that aids beginners can hinder the more experienced ([Kalyuga et al., 2003](https://doi.org/10.1207/S15326985EP3801_4)).

**AI as a judgement skill.** AI and data is a field of its own in Skills Academy, from AI foundations to responsible AI, and AI is part of how participants learn in every other field. The evidence sets the rules. In a field experiment with about a thousand students, open access to GPT-4 raised practice scores by 48% and left students 17% worse off on the test once it was removed; a tutor version that gave hints instead of answers largely removed the harm ([Bastani et al., 2025](https://doi.org/10.1073/pnas.2422633122)). Among 52 experienced developers learning a new library, those who used an AI assistant scored about 17% lower on a comprehension quiz than those who coded by hand ([Shen &amp;amp; Tamkin, 2026](https://doi.org/10.48550/arXiv.2601.20245)). So when using AI is the skill being learned, the AI tool is allowed in the assessment. When the assessment is of understanding without help, the companion does not lead the participant through it, and every pathway has at least one checkpoint without AI.

**Feedback that quotes you.** AI first feedback on submitted work is built and not yet open. Every score quotes the participant&apos;s work, and the quote must appear in it exactly once; a single quote that fails refuses the whole reading. The participant scores their own work first, and a reviewer does not see those scores until they have saved their own. There is no total, no grade and no pass or fail. A person on the class team reads the AI&apos;s feedback before the scores count. Work a person has reviewed is labelled verified; work only AI has scored is labelled AI-assessed.

**What is measured.** The quality of practical work against criteria, independent solution of a new task without AI, and a repeat after four to eight weeks. Enrolment, logins and videos watched are operating data, not learning.

### TEEI Mentorship: guidance into work

[TEEI Mentorship](/programmes/education/teei-mentorship/) is for people, especially Ukrainians, who are rebuilding a career or finding their way in a new job market. Mentees browse approved mentors by field, experience, language and availability, and request or book a session. Mentors give career and professional guidance: a CV, interview preparation, a job-search strategy, a change of career, how hiring works in their industry. They do not give therapy or legal advice, and they do not promise anyone a job.

**Why it is built this way.** Mentoring is not one thing. The research separates role model, adviser, coach, sponsor, network introduction and practical job-search help, and finds that the last three drive employment effects. In a randomised evaluation in Poland, women moving into technology who were mentored for four months by senior women in the field, with a consistent emphasis on introductions to hiring managers, were 15 percentage points more likely to be working in tech a year later, from 46% in the control group ([Athey &amp;amp; Palikot, 2026](https://www.nber.org/papers/w34750)). General social mentoring for refugees in Germany, by contrast, had no measurable effect on employment ([Jaschke et al., 2022](https://doi.org/10.1080/1369183X.2022.2058918)). The LinkedIn experiments cited above point the same way for mentoring: moderately weak ties, the kind a mentor&apos;s introduction creates, produce the most job mobility, especially in digital industries ([Rajkumar et al., 2022](https://doi.org/10.1126/science.abl4476)). Help with job search mattered most for those who lacked networks ([Battisti et al., 2019](https://doi.org/10.1016/j.labeco.2019.07.001)).

Three things follow. Mentoring is professional and aimed at work, and it is kept separate from buddy relationships. Mentees choose, and get structured information about which mentors have access to their target industry, so the choice does not follow sympathy alone. And the outcomes are a ladder rather than one rung.

**The outcome ladder.** Job-search activity, interviews, offers, work, hours, income, stability over three months, whether the job uses the person&apos;s qualifications, and progression over time. Each step is recorded separately, at three, six, twelve and twenty-four months. A move into work below a person&apos;s level is not the same as a good match. For Ukrainians in Europe, where 57% of those aged 20 to 64 were in work in 2025 and about 60% of them below their skill level ([UNHCR, 2026](https://data.unhcr.org/en/documents/details/120820)), qualification match is the measure that matters most.

### Connection: TEEI Women and Together for Ukraine

TEEI Women and Together for Ukraine are the frames through which TEEI reaches the communities it began with. The [Women Buddy Programme](/programmes/education/women-buddy-programme/) matches women with a buddy for connection and support, with a welcome relationship first, then support, then a pathway buddy for women who want to move into language, skills or work. A buddy is never responsible for someone&apos;s learning progress, job search or medical questions. The relationship is the point.

**Why it is built this way.** The strongest causal evidence for women is that learning together with someone they know changes what they do afterwards. In a randomised study of 636 women in Ahmedabad, business training changed women&apos;s business activity only when they were invited to attend with a friend, and the effect was strongest where social norms limited women&apos;s freedom of movement ([Field et al., 2016](https://doi.org/10.1257/pol.20140215)). Belonging works only where there is something real to belong to ([Walton et al., 2023](https://doi.org/10.1126/science.ade4420)). Small, structured groups do more than open channels: women in all-female virtual groups on a career platform were about seven percentage points more likely to finish training on time ([Melin, Yang &amp;amp; Goulas, 2026](https://doi.org/10.1287/orsc.2025.20602)). Light digital nudges to find a study partner, by contrast, did nothing measurable across 45,000 students in Ecuador ([Asanov et al., 2023](https://doi.org/10.1073/pnas.2216686120)).

So the programme lets women bring a friend, invests in fast matching, trains buddies in respect and reciprocity rather than in content, plans the end of a relationship so that loneliness does not return when a time-limited match ends, and removes practical barriers: times that fit school and childcare, short sessions, and the option to take part with children present. For Ukrainian refugee mothers in Germany, whose children attend childcare, participation in language and integration courses is about 25 percentage points higher ([Gambaro et al., 2026](https://doi.org/10.1215/00703370-12665544)). No buddy can replace childcare.

**What is measured.** Time from sign-up to first meeting, the share with at least three meetings, belonging and loneliness measured separately, and whether the relationship continues. Connection is not reduced to a funnel into employment.

### One Connection: peer support from someone who has been there

[One Connection](/programmes/health-equity/one-connection/), now in development, offers one-to-one peer support to people going through a stem cell or bone marrow transplant, from someone who has been through it. Most transplant patients today get no formal peer support, and most existing programmes still match by hand over the phone. Matching in One Connection uses experience, phase of treatment, language and the kind of contact a person wants, not a general interest profile with a diagnosis added. Peers share experience, never treatment advice, have their own introduction, and have a clear path to support from TEEI.

A pilot randomised trial of five telephone sessions with a transplant survivor found the approach feasible and acceptable, with preliminary psychosocial benefits ([Amonoo et al., 2026](https://doi.org/10.1182/bloodadvances.2025018918)). It is a pilot. It is not evidence for One Connection, and loneliness interventions in general help in the short term with low certainty ([Lasgaard et al., 2026](https://doi.org/10.1037/amp0001578)). One Connection will measure what peer support does for the people in it: perceived understanding, relevant support, depression, anxiety, quality of life after transplant and loneliness, with validated instruments.

Health information from One Connection never reaches a career profile, a company report or ordinary buddy matching. The programme reuses the platform&apos;s components, not its data.

### TEEI Meetings

Private meetings with invited guests, who join from a personal link without an account. Hosts, speakers and listeners each get exactly what their role needs.

### Evenhall

The free workspace for companies. Managers invite employees into TEEI Language, Skills Academy and TEEI Mentorship, where they take part in the same rooms as everyone else. Evenhall reads verified participation overnight and turns it into reports, with every figure linked to the record behind it.

![The Impact overview in Evenhall&apos;s demo workspace for a sample company, marked as demo mode with sample data. A sidebar lists the overview, analytics, people, volunteer time and impact stories. The main panel shows example figures for the year, 4,932.50 verified hours and 1,066 learners, and beside them 99% of the 5,000-hour annual target and the evidence on file.](/images/blog/teei-cloudflare-workers/evenhall-demo-impact-2026-10-23.webp#full,phone,1280x800 &quot;A company manager&apos;s view in Evenhall&apos;s demo workspace. The company and its figures are sample data.&quot;)

Source: TEEI, captured from Evenhall&apos;s public demo workspace with sample data, 23 October 2026.

Table: The programmes running today, the building blocks each one uses, and its status on 15 October 2026.

| Programme | Building blocks it uses | Status |
| --- | --- | --- |
| TEEI Language | Matching, live rooms, booking, measurement | Open |
| TEEI Language Courses | Classes, live rooms, booking, measurement | Running |
| Practice Companion | Practice, feedback, measurement | Opens after L2SAFE |
| Skills Academy | Classes, live rooms, feedback, booking, measurement | Open; AI feedback not yet open |
| TEEI Mentorship | Matching, live rooms, booking, measurement | Open |
| Women Buddy Programme | Matching, booking | Open |
| One Connection | Matching, booking | In development |
| TEEI Meetings | Live rooms | Open |
| Evenhall | Company reporting, measurement | Open |

## How a new programme starts

Two programmes show how this works in practice.

Skills Academy did not get a platform of its own. It runs on the TEEI Mentorship platform under its own address, uses the same rooms, with host, speaker and listener roles for webinars, and uses the same engine as Practice Companion for AI first feedback on submitted work. What was new was the programme itself: approving instructors, enrolment, the class formats and the certificate rules.

One Connection is built on the components of the Women Buddy Programme. What it adds is what makes peer support for transplant patients its own: matching on experience and phase of treatment, a defined peer role, an introduction for peers and a path to support. It reuses parts, not data. Nothing from One Connection reaches a career profile, a company report or ordinary buddy matching.

Every new programme follows the same six decisions.

![Six numbered decisions, who it is for, roles and matching, formats, rules and safeguarding, outcomes, and set-up on the shared blocks, the last with a note that AI meeting participants opens after its evaluation passes, followed by a band of the eight shared building blocks that every programme then runs on.](/images/blog/how-teei-works/six-decisions.svg#phone,fit &quot;Starting a programme: six decisions, then the shared blocks.&quot;)

Source: TEEI, drawn from the platform and programme documents, 15 October 2026.

1. **Who it is for**, and what it promises them.
2. **Roles and matching**: who the participants meet, and how they are matched.
3. **Formats**: one-to-one, groups, classes, practice, or a mix.
4. **Rules and safeguarding**: what is shared, what never is, and who steps in when something goes wrong.
5. **Outcomes**, defined in the measurement register before any data exists.
6. **Set-up on the shared blocks.** Where AI works directly with participants, it opens only after its evaluation has passed, as Practice Companion does with L2SAFE.

Everything else, from rooms and bookings to verified hours and company reports, is already there.

## AI at TEEI: where it checks, and who decides

TEEI uses AI in five places where it meets participants, and in each one a written policy or a person makes the decision.

**Screening volunteer applications.** When someone applies to volunteer, a model reads the application and returns a structured verdict: a decision, red flags, ambiguities, positive signals and a confidence score. For TEEI Language conversation partners, the platform may approve an application automatically, and only on a clean pass against a written policy. Mentors who apply on their own and course teachers go to a person, whatever the verdict. The application text itself is not stored.

**Safeguarding in chat.** Chats between participants are theirs. No one at TEEI reads along. The platform&apos;s own software checks each message and rates whether it may need attention. It keeps the rating, not the text. Only a message rated as a possible concern reaches a person at TEEI responsible for safeguarding, who decides what happens.

**A help assistant that answers only from TEEI&apos;s own articles.** It checks its citations against those articles before an answer is shown; when it cannot answer, the person is told so and can reach a member of staff. A question that touches on safety gets emergency guidance and no model text at all.

**Review of return plans.** When a volunteer has been unlisted after a breach and submits a plan to return, a model reports what the plan contains. It does not decide. The platform&apos;s policy decides, and at the strictest level a member of staff does. The model never relists anyone.

**A guide for group practice.** The volunteer leading a Speaking Club can ask for a guide pitched across the learners&apos; levels. The volunteer decides whether to use it.

A prompt that decides something cannot ship without a passing evaluation. Each such check has a recorded test set, from 14 to 94 cases as of 14 October 2026, and a threshold it must meet: zero false automatic approvals for screening, zero escapes for safeguarding. If the prompt, the decision rule or the output format changes, the fingerprint no longer matches the committed receipt, and the deployment stops until the evaluation has been run again and passed. All product AI runs on Amazon Bedrock through EU cross-region inference, and TEEI leaves model logging off, so its own AWS account keeps no copy of prompts or replies.

Practice Companion is the sixth place, and the one that works with learners directly over time. That is why it opens only after L2SAFE.

## What we chose not to build

Some of the model is defined by what is left out.

- **No persona.** The companion has no name, no face and no avatar. It is not a friend, and it does not pretend to be one.
- **No score to chase.** No counters, streaks, points or mastery percentages. Progress is a word, never a number, because CEFR bands, the A1 to C2 levels used to describe language ability across Europe, are not a scale you can do arithmetic on.
- **No level as a gate.** A level sets how the companion writes to a learner. It never decides access to a room, a class or a person.
- **No blended reading.** A stored level is one whole reading with its own quotes, never an average of three.
- **No model choosing the questions.** The task plan is code; the model only words a question.
- **No free text to the volunteer.** The shelf carries one line the learner chose. The volunteer sees that and nothing else.
- **No audio stored.** Spoken practice is stored as text.
- **No media through TEEI&apos;s servers.** Audio and video go between browsers and the media service, never through TEEI.
- **No hours from calendars.** A booking says two people planned to meet. Hours come from the room.
- **No outcomes claimed from activity.** A session record proves that time was given. It does not prove what the time achieved.
- **No fees, and no donation as a hidden next step.** Participation is free for participants and for the companies whose employees volunteer.

## What comes next

More programmes on the same platform, for more people. TEEI&apos;s offer is widening beyond the groups it began with, and a new group does not need a new system: it needs the six decisions above.

The next step reaches beyond TEEI&apos;s own programmes. Other NGOs will be able to run their own programmes on the platform, free, as a service. They bring the participants, the volunteers and the expertise, and keep responsibility for their programme. The platform brings booking, rooms, practice, feedback and verified records, with the same definitions of activity TEEI uses, so that work by different organisations can be compared and still reported separately.

The aim is reach through many operators on shared infrastructure, rather than one organisation coordinating every relationship itself.

## How we measure

We keep three things apart: activity, results and effects.

![Three levels of evidence, one box each. Activity: who took part, in what, and for how long, counted from room events, where a session with missing events is marked unknown, never a no-show; this is the level TEEI publishes live today. Results: what changed for the person, measured with named instruments on named dates. Effects: what the programme caused, which needs a comparison, for example proactive connections against open access. Each level names what counts as evidence on it.](/images/blog/how-teei-works/measurement-ladder.svg#phone,fit &quot;The measurement ladder: activity, results, effects.&quot;)

Source: TEEI, drawn from the measurement register, 15 October 2026.

**Activity** is who took part, in what, and for how long. On the public counter and in company reports, a verified hour is time that both people were connected to the same room, settled after the session, with at least five minutes together; a person&apos;s own record counts all the time they were measured in the room. A session with missing events is marked unknown, never a no-show, because a missing signal is not proof that someone stayed away. By 14 October 2026, 1,440 one-to-one sessions on the current platform had been settled as completed this way. The [public counter](https://language.theeducationalequalityinstitute.org/api/lc/public-stats), which combines completed sessions from the platform TEEI used before with those on the current one, read 12,560 sessions and 9,462.9 hours of conversation across TEEI Language and TEEI Mentorship on 15 October 2026 at 19:02 UTC, with members in 148 countries.

**Results** are what changed for the person: a skill shown in new work, a level read from a conversation, a step towards work. Loneliness, belonging and the value of peer support are getting their own definitions in the register. Loneliness will be measured with the three-item UCLA loneliness scale ([Hughes et al., 2004](https://doi.org/10.1177/0164027504268574)) at the start and after about twelve weeks; the timing is TEEI&apos;s design, not part of the scale. Until data exists, these measures show &quot;Reserved&quot;. That is not zero.

**Effects** are what a programme caused, and they need a comparison. The first one we propose asks whether proactive connections, an offer of a buddy in the first week and a planned mentor conversation after the first practical task, add value over open access to the same offers.

Where AI measures, it is tested before it counts. Practice Companion&apos;s level scorer reads every conversation three times and keeps the lowest band when the readings disagree. On 15 test conversations frozen before their first run, as of 14 October 2026, it matched the intended band every time, a result consistent with a true match rate as low as about 78 percent. Every reading stays labelled uncalibrated until blind ratings by experienced people show how far they agree.

## What we do not know yet

- **The model as a whole.** No study has tested it. The case rests on evidence about each part.
- **Language and work outside the Nordic setting.** The strongest evidence linking language to work comes from refugees learning the host country&apos;s language in the Nordic countries. For women in Latin America learning English for remote work, the evidence is thinner and mostly correlational.
- **A digital, cross-border buddy.** The friend effect was found in face-to-face training in India. A buddy met online, across borders, has not been tested.
- **AI with adults rebuilding their lives.** Most studies of AI in learning are short and involve students.
- **Time.** Many participants have very little of it. A model with many parts can become a burden if it asks for too much at once.
- **TEEI&apos;s own outcomes for this model.** The register already holds first outcome rows for TEEI Language, such as recorded level changes and learners&apos; own confidence ratings. What does not exist yet is the comparison that would show what the model as a whole causes. We will publish outcomes as they arrive, with their definitions, sample sizes and missing data.

## Who the model serves, and why that shapes it

The people TEEI works with today face different barriers, and the research shows that the same offer does different work for each of them.

**Ukrainians in Europe** are often highly qualified. Among those aged 20 to 64, 57% were in work in 2025, 22 percentage points below host-country citizens, and about 60% of those in work were below their skill level ([UNHCR, 2026](https://data.unhcr.org/en/documents/details/120820)). Some knowledge of the local language raised the share in work by 13 percentage points, yet higher language levels brought no further gain, and most of those in work were in jobs below their qualifications. For this group the barrier is not a lack of competence but the move from competence to suitable work: recognition of qualifications, professional language and networks. The connection that matters most is Language with Work, in parallel.

**Women in Latin America and the Caribbean** face a different market. Women&apos;s labour-force participation is 52.1% against 74.3% for men, and nearly half of all workers are informal (ILO Labour Overview 2024). Internet use is close to gender parity, but practical digital skills are not: in one six-country study, 35% of women against 24% of men said they could not use a smartphone ([Agüero et al., 2020](https://doi.org/10.18235/0002235)). Women also carry up to twice as much unpaid work as men ([ILO, 2026](https://www.ilo.org/sites/default/files/2026-03/IT56-Tiempo-trabajo-bienestar-Am%C3%A9rica-Latina_v4.pdf)). For this group, Work can mean formalisation, higher income from their own trade or remote work rather than a first job, Skills must start lower than an AI course, and time is the scarcest resource.

**Loneliness** cuts across both. About one in six people worldwide report it, with small differences between men and women (WHO Commission on Social Connection, 2025). Social isolation, the objective state of having few relationships, is a different thing: a woman can have a close family network and still be lonely, or lack professional ties. TEEI keeps the two apart in its measurement register.

The model is built so that a new group with a new mix of barriers needs a new configuration, not a new platform. That is also why TEEI&apos;s offer is widening beyond the groups it began with.

## The five articles this one builds on

- [How TEEI Turns Amazon Chime SDK Events into Verified Hours](/blog/from-practice-room-to-company-report-teei-amazon-chime-sdk-en/)
- [Inside Practice Companion: Rooms, Evidence and Level](/blog/inside-practice-companion-rooms-evidence-and-level-en/)
- [How TEEI Runs Its Platforms on Cloudflare Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/)
- [What TEEI Runs on AWS](/blog/everything-teei-runs-on-aws-en/)
- [How Corporate Volunteering Works at TEEI](/blog/how-corporate-volunteering-works-at-teei-en/)

## Sources

- Abebe, Caria, Fafchamps, Falco, Franklin &amp;amp; Quinn (2021). Anonymity or Distance? Job Search and Labour Market Exclusion in a Growing African City. *Review of Economic Studies*, 88(3), 1279-1310. doi:10.1093/restud/rdaa057
- Agüero, Bustelo &amp;amp; Viollaz (2020). ¿Desigualdades en el mundo digital? Brechas de género en el uso de las TIC. Inter-American Development Bank. doi:10.18235/0002235
- Amonoo et al. (2026). A pilot randomized clinical trial of a peer support intervention for hematopoietic stem cell transplantation. *Blood Advances*, 10(11), 3987-3997. doi:10.1182/bloodadvances.2025018918
- Arendt (2022). Labor market effects of a work-first policy for refugees. *Journal of Population Economics*, 35(1), 169-196. doi:10.1007/s00148-020-00808-z
- Arendt &amp;amp; Bolvig (2023). Trade-offs between work-first and language-first strategies for refugees. *Economics of Education Review*, 92, 102353. doi:10.1016/j.econedurev.2022.102353
- Asanov et al. (2023). System-, teacher-, and student-level interventions for improving participation in online learning at scale in high schools. *Proceedings of the National Academy of Sciences*, 120(30). doi:10.1073/pnas.2216686120
- Athey &amp;amp; Palikot (2026). Effective and Scalable Programs to Facilitate Labor Market Transitions for Women in Technology. NBER Working Paper 34750; arXiv:2211.09968
- Bastani et al. (2025). Generative AI without guardrails can harm learning: Evidence from high school mathematics. *Proceedings of the National Academy of Sciences*, 122(26). doi:10.1073/pnas.2422633122
- Battisti, Giesing &amp;amp; Laurentsyeva (2019). Can job search assistance improve the labour market integration of refugees? Evidence from a field experiment. *Labour Economics*, 61, 101745. doi:10.1016/j.labeco.2019.07.001
- Card, Kluve &amp;amp; Weber (2018). What Works? A Meta Analysis of Recent Active Labor Market Program Evaluations. *Journal of the European Economic Association*, 16(3), 894-931. doi:10.1093/jeea/jvx028
- Carlana &amp;amp; La Ferrara (2025). Apart but Connected: Online Tutoring, Cognitive Outcomes, and Soft Skills. *American Economic Review*, 115(10), 3487-3513. doi:10.1257/aer.20240401
- Clausen, Heinesen, Hummelgaard, Husted &amp;amp; Rosholm (2009). The effect of integration policies on the time until regular employment of newly arrived immigrants: Evidence from Denmark. *Labour Economics*, 16(4), 409-417. doi:10.1016/j.labeco.2008.12.006
- Dahlberg, Egebark, Vikman &amp;amp; Özcan (2022). Labor market integration of low-educated refugees: RCT evidence from an ambitious integration program in Sweden. arXiv:2203.00487
- Double, McGrane &amp;amp; Hopfenbeck (2020). The Impact of Peer Assessment on Academic Performance: A Meta-analysis of Control Group Studies. *Educational Psychology Review*, 32(2), 481-509. doi:10.1007/s10648-019-09510-3
- Evans, Kearney, Perry &amp;amp; Sullivan (2020). Increasing Community College Completion Rates Among Low-Income Students: Evidence from a Randomized Controlled Trial Evaluation of a Case-Management Intervention. *Journal of Policy Analysis and Management*, 39(4), 930-965. doi:10.1002/pam.22256
- Fein &amp;amp; Dastrup (2022). Benefits that Last: Long-Term Impact and Cost-Benefit Findings for Year Up. OPRE Report 2022-77. Office of Planning, Research, and Evaluation, Administration for Children and Families. [Read the report](https://acf.gov/opre/report/benefits-last-long-term-impact-and-cost-benefit-findings-year-up)
- Field, Jayachandran, Pande &amp;amp; Rigol (2016). Friendship at Work: Can Peer Effects Catalyze Female Entrepreneurship? *American Economic Journal: Economic Policy*, 8(2), 125-153. doi:10.1257/pol.20140215
- Foged, Hasager, Peri, Arendt &amp;amp; Bolvig (2024). Language Training and Refugees&apos; Integration. *Review of Economics and Statistics*, 106(4), 1157-1166. doi:10.1162/rest_a_01216
- Gambaro, Schmitz, Huebener &amp;amp; Spiess (2026). Overlooked Potential? Childcare Services and Ukrainian Refugee Mothers in Germany. *Demography*, 63(3), 979-1005. doi:10.1215/00703370-12665544
- Hughes, Waite, Hawkley &amp;amp; Cacioppo (2004). A Short Scale for Measuring Loneliness in Large Surveys. *Research on Aging*, 26(6), 655-672. doi:10.1177/0164027504268574
- ILO (2024). *Labour Overview 2024: Latin America and the Caribbean*. International Labour Organization.
- ILO (2026). Gontero, S. *Tiempo de trabajo y bienestar en América Latina*. Informes Técnicos OIT Cono Sur 56. [Read the report](https://www.ilo.org/sites/default/files/2026-03/IT56-Tiempo-trabajo-bienestar-Am%C3%A9rica-Latina_v4.pdf)
- Jaschke, Löbel, Krieger, Legewie, Kroh, Jacobsen &amp;amp; Schacht (2022). Mentoring as a grassroots effort for integrating refugees: evidence from a randomised field experiment. *Journal of Ethnic and Migration Studies*, 48(17), 4085-4105. doi:10.1080/1369183X.2022.2058918
- Kahlon et al. (2021). Effect of Layperson-Delivered, Empathy-Focused Program of Telephone Calls on Loneliness, Depression, and Anxiety Among Adults During the COVID-19 Pandemic. *JAMA Psychiatry*, 78(6), 616-622. doi:10.1001/jamapsychiatry.2021.0113
- Kalyuga, Ayres, Chandler &amp;amp; Sweller (2003). The Expertise Reversal Effect. *Educational Psychologist*, 38(1), 23-31. doi:10.1207/S15326985EP3801_4
- Kim &amp;amp; Webb (2022). The Effects of Spaced Practice on Second Language Learning: A Meta-Analysis. *Language Learning*, 72(1), 269-319. doi:10.1111/lang.12479
- Koedinger, Kim, Jia, McLaughlin &amp;amp; Bier (2015). Learning is not a spectator sport: Doing is better than watching for learning from a MOOC. *Learning@Scale 2015*, 111-120. doi:10.1145/2724660.2724681
- Lasgaard et al. (2026). Are loneliness interventions effective for reducing loneliness? A meta-analytic review of 280 studies. *American Psychologist*, 81(1), 36-52. doi:10.1037/amp0001578
- Li (2010). The Effectiveness of Corrective Feedback in SLA: A Meta-Analysis. *Language Learning*, 60(2), 309-365. doi:10.1111/j.1467-9922.2010.00561.x
- Lyster &amp;amp; Saito (2010). Oral feedback in classroom SLA: A meta-analysis. *Studies in Second Language Acquisition*, 32(2), 265-302. doi:10.1017/S0272263109990520
- Lyu, Lai &amp;amp; Guo (2025). Effectiveness of Chatbots in Improving Language Learning: A Meta-Analysis of Comparative Studies. *International Journal of Applied Linguistics*, 35(2), 834-851. doi:10.1111/ijal.12668
- Mackey &amp;amp; Goo (2007). Interaction research in SLA: A meta-analysis and research synthesis. In A. Mackey (Ed.), *Conversational Interaction in Second Language Acquisition: A Collection of Empirical Studies*. Oxford University Press.
- Martinson &amp;amp; Glosser (2022). Washington State&apos;s Integrated Basic Education and Skills Training (I-BEST) Program: Six-Year Impact Report. OPRE Report 2022-64. Office of Planning, Research, and Evaluation, Administration for Children and Families. [Read the report](https://www.abtglobal.com/files/insights/reports/2022/i-best-6-year-report-mar-2022.pdf)
- Melin, Yang &amp;amp; Goulas (2026). With a Little Help from My (Girl) Friends: Field Evidence on Gender Homophily and Women&apos;s Training Outcomes. *Organization Science*. doi:10.1287/orsc.2025.20602
- Norris &amp;amp; Ortega (2000). Effectiveness of L2 Instruction: A Research Synthesis and Quantitative Meta-analysis. *Language Learning*, 50(3), 417-528. doi:10.1111/0023-8333.00136
- Plough &amp;amp; Gass (1993). Interlocutor and task familiarity: Effects on interactional structure. In G. Crookes &amp;amp; S. Gass (Eds.), *Tasks and Language Learning: Integrating Theory and Practice*. Multilingual Matters.
- Rajkumar, Saint-Jacques, Bojinov, Brynjolfsson &amp;amp; Aral (2022). A causal test of the strength of weak ties. *Science*, 377(6612), 1304-1310. doi:10.1126/science.abl4476
- Reich &amp;amp; Ruipérez-Valiente (2019). The MOOC pivot. *Science*, 363(6423), 130-131. doi:10.1126/science.aav7958
- Shen &amp;amp; Tamkin (2026). How AI Impacts Skill Formation. arXiv:2601.20245. Preprint.
- Spada &amp;amp; Tomita (2010). Interactions Between Type of Instruction and Type of Language Feature: A Meta-Analysis. *Language Learning*, 60(2), 263-308. doi:10.1111/j.1467-9922.2010.00562.x
- Teimouri, Goetze &amp;amp; Plonsky (2019). Second language anxiety and achievement: A meta-analysis. *Studies in Second Language Acquisition*, 41(2), 363-387. doi:10.1017/S0272263118000311
- UNHCR (2026). *Labor Market Integration of Ukrainian Refugees in Europe*. Data collected March to July 2025. [Read the report](https://data.unhcr.org/en/documents/details/120820)
- Walton et al. (2023). Where and with whom does a brief social-belonging intervention promote progress in college? *Science*, 380(6644), 499-505. doi:10.1126/science.ade4420
- WHO Commission on Social Connection (2025). *From loneliness to social connection: charting a path to healthier societies*. World Health Organization, 30 June 2025.
- Wisniewski, Zierer &amp;amp; Hattie (2020). The Power of Feedback Revisited: A Meta-Analysis of Educational Feedback Research. *Frontiers in Psychology*, 10, 3087. doi:10.3389/fpsyg.2019.03087</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>TEEI platform</category><category>Research</category><category>Programmes</category><category>Verified hours</category><category>Evenhall</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>Keeping Astro Content, Sitemaps and Search in Sync</title><link>https://theeducationalequalityinstitute.org/blog/keeping-astro-content-sitemaps-and-search-in-sync-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/keeping-astro-content-sitemaps-and-search-in-sync-en/</guid><description>We can read from one Astro collection and still disagree on what is published. For a built search index, we filter preview drafts out again.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>Our preview reader turns `draft: true` into `draft: false`. That lets us preview an article through the same template as a published post, but it changes what the next consumer can infer from the field. If we built a search index from that view, we could freeze draft titles into a public file. We therefore preserve a separate preview marker and filter it out when building the index.

The same Markdown folder also feeds RSS, public JSON, text exports and article pages. The sitemap takes a different path through built routes and HTML. On 15 October 2026 we compared six production surfaces and found the same 64 English article addresses. This note explains the eligibility rules behind those outputs, where they can disagree and how to compare membership rather than counts.

**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.

## Seven surfaces from one folder

We run the website as an Astro 7 site on a Cloudflare Worker, [alongside TEEI&apos;s other platforms](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/), and keep the blog as a content collection: a [`glob()` loader](https://docs.astro.build/en/reference/content-loader-reference/#glob-loader) over the Markdown folder and a Zod schema every file must pass. We decide where a post may appear with three fields: `lang` (`en`, `uk`, `no` or `fr`), `draft` and `unlisted`, both false unless we set them in a post.

We make most surfaces at build time, through page templates, the sitemap integration and [static file endpoints](https://docs.astro.build/en/guides/endpoints/#static-file-endpoints). We answer the main RSS feed and the search records in the Worker on every request, using the collection loaded at build time. A request-time `generatedAt` timestamps a response; it does not mean we reread the Markdown then. A content change reaches these surfaces through a new build.

The selectors below describe our source implementation checked on 15 October 2026. The production comparison later in this note is a separate observation from that day.

Table: The seven surfaces from the blog folder, with baseline eligibility when the draft preview flag is off.

| Surface | Collection view | Eligibility | Response assembled |
|---|---|---|---|
| Article page, `/blog//` | Shared reader | Published English posts, plus the unlisted state (noindex) | Build |
| Sitemap | Built routes | URLs that pass the URL filter and output checks | Build, then prune |
| RSS, `/rss.xml` | Authored state | Published posts in every language | Request |
| `/llms-full.txt` | Authored state | Published English, Norwegian and Ukrainian posts | Build |
| `/api/blog/posts.json` | Authored state | Published posts in every language | Build |
| Palette index, `/blog/data/palette.json` | Shared reader | Published English posts, excluding shown drafts | Build |
| Algolia records, `/api/search/records.json` | Shared reader | Published English posts, excluding shown drafts | Request |

![How we build seven blog surfaces from one Markdown collection. We read it directly for RSS, the public post list and llms-full.txt, and use a shared reader for article pages and both search datasets. We exclude preview drafts from search. Separately, we build the sitemap from routes, filter URLs and prune noindex pages.](/images/blog/keeping-astro-content-sitemaps-and-search-in-sync/surfaces.svg#wide,phone,fit &quot;Seven surfaces we build from one blog folder, and the publication rule we apply to each.&quot;)

Source: TEEI, drawn from the website’s code, 15 October 2026.

## Authored state and the preview view answer different questions

Astro&apos;s documentation shows [an optional filter callback on `getCollection()`](https://docs.astro.build/en/guides/content-collections/#filtering-collection-queries) that filters on `draft`. In our feeds and JSON files we do exactly that, each in its own file, with the language rule its surface needs.

*Adapted for publication.*

```ts
// RSS: published posts in every language
const posts = await getCollection(&apos;blog&apos;, ({ data }) =&amp;gt; !data.draft);

// llms-full.txt: published posts in three languages
const INDEXED_LANGS = new Set([&apos;en&apos;, &apos;no&apos;, &apos;uk&apos;]);
const all = await getCollection(&apos;blog&apos;);
const indexed = all.filter((p) =&amp;gt; !p.data.draft).filter((p) =&amp;gt; INDEXED_LANGS.has(p.data.lang));
```

For article pages and both search surfaces we use one shared reader. It is the only place in the site&apos;s source where we read the draft preview flag, a boolean `astro:env` server variable. With that flag on, we can view drafts in place, each with a Draft mark. The transformation is a pure function:

*Adapted for publication.*

```ts
export function c5Collection(posts: T[], showDrafts: boolean): C5Visible[] {
  if (!showDrafts) return posts;
  return posts.map((post) =&amp;gt; {
    if (!post.data.draft || isUnlisted(post)) return post;
    return {
      ...post,
      data: { ...post.data, draft: false },
      c5Draft: true as const,
    };
  });
}
```

With the flag on, we copy each shown draft and its `data`, set `draft: false` on the copy and attach `c5Draft` outside `data`. After that rewrite, `!data.draft` no longer proves that an author published the post. Where we call `getCollection()` directly, we still read the authored state. Through the shared reader, we ask a second question, `isC5Draft()`: in the search indexes to exclude shown drafts, in the article template to label them and set noindex.

## A prerendered search index needs its own publication filter

That second question matters most for the palette index, because we prerender it through the shared reader, and a prerendered file anyone can download keeps whatever we built it with. So we remove preview drafts from the built palette index even when the flag is on: in its filter we ask whether this is a build, not whether we enable the flag.

*Adapted for publication.*

```ts
/**
 * The posts we may list on a built search surface. On the dev server we keep every input post.
 * In a build we remove shown drafts whether the preview flag is on or not, because we keep in a
 * prerendered index what we built it with.
 */
export function searchablePosts(posts: C5Visible[], dev: boolean): C5Visible[] {
  return dev ? posts : posts.filter((post) =&amp;gt; !isC5Draft(post));
}

// The palette index, built with the site
const posts = publishedEnglish(searchablePosts(await loadC5Collection(), import.meta.env.DEV));
```

The build invariant is independent of the preview flag: no post marked `c5Draft` may enter the palette index. We enforce language and publication status separately through `publishedEnglish`. Algolia records always exclude shown drafts; the server-rendered search page excludes them outside the dev server.

In the unit test, with preview on, we exclude the draft from the build selection and keep it in the dev-server one, and check that nothing changes with the flag off. That test covers selector decisions. Inspecting the generated JSON is a separate check.

## We take ranking from Algolia and eligibility from our collection

While Algolia is configured, we send each non-empty palette query to Algolia through the Worker once typing pauses, cancelling the previous request; without an answer from Algolia, we search in the Worker ourselves. For an empty query, and when the Worker&apos;s search route is unavailable, we use the prebuilt index in the browser. We fill Algolia from the search records and cover the sync and fallback in [How Algolia powers multilingual search at TEEI](/blog/how-teei-uses-algolia-to-find-what-people-meant-en/). For a blog query we ask Algolia for `postId` only and map the hits back onto the posts we may show.

*Verbatim excerpt.*

```ts
export function postsFromAlgolia(posts: T[], answer: AlgoliaAnswer): T[] {
  const byId = new Map(publishedEnglish(posts).map((post) =&amp;gt; [post.id, post]));
  return answer.hits.flatMap((hit) =&amp;gt; {
    const post = typeof hit.postId === &apos;string&apos; ? byId.get(hit.postId) : undefined;
    return post ? [post] : [];
  });
}
```

We decide from our own collection which ids may appear, and walk Algolia&apos;s hits in the order we received them, so rejected hits disappear and the survivors keep Algolia&apos;s ranking. We take each title and link from the local post, not from the index. If the index still holds a post we have taken down, or we ever fed it a draft or an unlisted article by mistake, we drop the hit before we answer. In a unit test we pass hits for a draft, an unlisted post, a Norwegian post and a published English post, and check that only the English one comes back.

We request no replacements for rejected hits, so we can show fewer articles even when more eligible results exist, and we cannot recover a published post Algolia did not return: we limit which hits we show, not whether the index is complete. Nor do we promise readers identical results on every path: we match different fields with Algolia and our two fallbacks, and in the Worker we bound the query first.

## Drafts, the unlisted state and posts held back

Our publication pipeline handles three unpublished cases, and a post in each one leaves the surfaces by a different route.

Table: Eligibility for English posts handled by our collection routes, in a production build with the draft flag off. Hand-built routes have their own publication rules and are not covered here.

| Post | Article page | Lists and feeds | Sitemap | Search palette and Algolia | Markdown copy |
|---|---|---|---|---|---|
| Published | Yes | Yes | Yes | Yes | Yes |
| Draft | No (404) | No | No | No | No (404) |
| Unlisted | Yes, `noindex, nofollow` | No | No | No | No (404) |
| Held back by a build flag | No (not in the collection) | No | No | No | No |

We leave **a draft** out of every surface&apos;s filter, and we build no English article page for it. For one English draft in production on 15 October 2026 at 14:18 UTC, we got a 404 for its page and for its Markdown copy.

**The unlisted state** is for a post meant to be reached through its link without appearing in discovery surfaces. The state takes `draft: true` plus `unlisted: true`, so wherever we leave drafts out, such a post stays out too. Only the English article route builds it, with `robots` set to `noindex, nofollow`. These are publication and indexing rules, rather than access control. If someone sets `unlisted: true` and forgets `draft: true`, we would list that post everywhere while its author believes it is unlisted, so the article route rejects that combination at build time. A single `visibility` field can avoid invalid combinations when a publication model outgrows two booleans.

**Posts held back by a build flag** are the bluntest tool: while the flag is off, we exclude them with negated patterns in the `glob()` loader, so they exist for no surface.

## How we build the sitemap from pages and routes

Through [`@astrojs/sitemap`](https://docs.astro.build/en/guides/integrations-guide/sitemap/) we list the pages our build produced and every route with a fixed path. In its [`filter(page)`](https://docs.astro.build/en/guides/integrations-guide/sitemap/#filter) we receive only a URL, never the post behind it, so there we cannot tell a draft from a published article. In a production build with the draft flag off, the drafts our article template handles never reach the filter, because we do not build them. Pages in the unlisted state are built, so we remove them later: after `astro build` finishes, we open the sitemap, find the built file for every `` and classify it.

According to our code comment, on 10 July 2026 we falsely reported no noindex pages because we matched only quoted attributes. We minify our production HTML with unquoted attributes, so we now match both forms. The pattern still expects `name` before `content`, the order in which our SEO component writes the robots tag. In a test with synthetic tags on 22 October 2026, it missed `noindex` when `content` came first and read a `data-name=robots` attribute as the robots name. It checks the HTML we generate, not arbitrary markup.

*Adapted for publication.*

```js
// Prod HTML is minified (unquoted attrs), so the match does not depend on quotes.
const NOINDEX_META =
  /]*\bname=[&quot;&apos;]?robots[&quot;&apos;]?[^&amp;gt;]*\bcontent=[&quot;&apos;]?[^&quot;&amp;gt;]*\bnoindex\b/i;

export function classifyUrl(url, distDir) {
  const pathname = pathnameOf(url);
  // We omit our other path and page-validity checks here.
  const file = distFileFor(url, distDir);
  if (!fs.existsSync(file)) {
    if (isServerRenderedIndexablePath(pathname) &amp;amp;&amp;amp; builtWorkerHasRoute(pathname, distDir)) {
      return { indexable: true, reason: &apos;ok-server-rendered&apos; };
    }
    return { indexable: false, reason: &apos;no-built-page&apos; };
  }

  const html = fs.readFileSync(file, &apos;utf8&apos;);
  if (NOINDEX_META.test(html)) return { indexable: false, reason: &apos;noindex&apos; };

  return { indexable: true, reason: &apos;ok&apos; };
}
```

If an entry&apos;s `` is not indexable, we remove the whole `` block; in an entry we keep, we remove only the failing hreflang alternates. So a page in the unlisted state leaves the sitemap without anyone listing it. In return, we depend on the built HTML carrying the right robots directive: an otherwise eligible prerendered page without its `noindex` stays in the sitemap. We use one classifier to prune the sitemap and then to check it, so we apply the same policy twice: consistently, but with no independent proof that it is correct. We test its decisions separately, with fixtures.

![Sequence of five steps in which we make the sitemap. In a production build with the draft flag off, we build published article pages, and pages in the unlisted state, from the template but never other drafts. We list every built page and fixed route, plus the server-rendered pages we name, and filter by URL. After the build, we open each built page and prune every entry marked noindex, which is how a page in the unlisted state leaves, and in the entries we keep we drop each failing hreflang alternate. Last, we check that every remaining URL is indexable. A marker at the prune step shows the 24 September 2026 failure: we had the prune look for a Worker entry file that we had not yet renamed in a later step, and we dropped every server-rendered page.](/images/blog/keeping-astro-content-sitemaps-and-search-in-sync/sitemap.svg#wide,phone,fit &quot;How we generate the sitemap during the build, prune it afterwards and check the result, including the failure we fixed on 24 September 2026.&quot;)

Source: TEEI, drawn from the website’s build configuration and the fix of 24 September 2026.

Server-rendered pages need the opposite treatment: through the integration we [cannot generate entries for dynamic routes in SSR mode](https://docs.astro.build/en/guides/integrations-guide/sitemap/), and we find no built HTML for them when pruning. We pass the URLs from one explicit list to [`customPages`](https://docs.astro.build/en/guides/integrations-guide/sitemap/#custompages) and keep each entry in the prune only when we find its exact route string in the generated Worker. So we depend on how that file is serialised, and we still need a rendered response to learn what the page returns.

## When we ran the prune before the rename (24 September 2026)

We run the prune in a chain of post-build steps, and in a later step we rename the Worker&apos;s entry file so we can wrap it with a scheduled handler. On 24 September 2026 we found that we had run the prune before that rename while having it look only for the renamed file. We mistook the missing file for missing routes and removed every opted-in server-rendered page from every sitemap we shipped, including the two pages we had added to that list on 23 September.

We fixed it the same day. We had the prune prefer the renamed entry file and fall back to its original name; after the wrapping step, the original name belonged to the wrapper, and only the renamed file still held Astro&apos;s route manifest. We took the list of server-rendered pages from one source instead of a second, hand-kept list in the Astro config, verified each opted-in page after the build, and covered both entry names in regression tests. In the full build that day we found every opted-in route in the sitemap. We did not measure what the gap had cost in search results.

A later change split the server build into chunks, and Astro&apos;s route manifest moved out of both entry files into a file under `dist/server/chunks/`. In our code as of 22 October 2026, the prune therefore checks the two entry files first and then the files in `dist/server/chunks/*.mjs`, and it uses the first one that contains route entries. A file that exists but holds no routes no longer counts, because it would drop every server-rendered page again.

## Feed links must use the route&apos;s identity

In Astro&apos;s [RSS recipe](https://docs.astro.build/en/recipes/rss/#using-content-collections), each link is built as `/blog/${post.id}/`, which is correct whenever you use the collection id as the route&apos;s slug, as we do. We give our ids a language suffix such as `-en`. On 9 May 2026 we stripped it from the feed&apos;s links, and according to the note we left in the feed&apos;s code, 50 of the 51 links then pointed at a 404. We now build each item of the main feed and the category feeds, link included, with one shared function and a path helper that keeps the suffix. We have no test dedicated to feed links; we check them from outside with the comparison below.

## What we measured

We took all values with `curl -s` against the public site on 15 October 2026, starting at 13:51 UTC.

Table: The blog&apos;s public surfaces in production, 15 October 2026.

| Surface | Entries |
|---|---|
| `sitemap-0.xml` | 347 URLs, 64 of them English articles |
| `/blog/data/palette.json` | 64 posts, all English articles, plus 9 sections |
| `/api/search/records.json` | 64 blog records, all English articles, plus help answers |
| `/api/blog/posts.json` | 72 posts, 64 of them English articles |
| `/rss.xml` | 72 items, 64 of them English articles |
| `/llms-full.txt` | 72 URLs, 64 of them English articles |

We reduced each surface to its set of English article addresses and compared each set with the sitemap&apos;s: every difference was empty. We found no draft&apos;s article address on any of them, checked against our list of drafts that afternoon. In a separate check at 21:50 UTC, none of the 63 draft titles then in our source folder appeared in the saved responses.

This establishes agreement for English article addresses on that day. It does not establish completeness: a post excluded by the loader can disappear from every output at once. Sets also hide duplicates. We read the records supplied to the sync, rather than the live Algolia index, and did not measure deploy lag or the sitemap in staging. We read the tests named in this note; we did not run them for that measurement.

## Verify this

On our site, the sitemap, the palette index and the search records are public, so you can repeat the comparison on them. On your own site:

1. Choose one publication scope for every surface; ours was published English articles.
2. Take the article paths from your sitemap, every JSON index and your feed, normalised and limited to that scope.
3. Choose, independently of your collection, the set of addresses you expect.
4. Build with your preview flag off and on. Compare each listing against that set, using the generated files for prerendered outputs and responses from the same candidate for request-time outputs.

For each surface, keep the extracted paths in an array until after checking duplicates. This small comparison takes that array and your independently chosen expected paths:

*Illustrative check for your own exports.*

```js
function compareSurface(expectedPaths, actualPaths) {
  const expected = new Set(expectedPaths);
  const actual = new Set(actualPaths);
  return {
    missing: [...expected].filter((path) =&amp;gt; !actual.has(path)),
    unexpected: [...actual].filter((path) =&amp;gt; !expected.has(path)),
    duplicateCount: actualPaths.length - actual.size,
  };
}
```

The inputs must use the same language scope and URL normalisation. A clean result has empty `missing` and `unexpected` arrays and zero duplicates. If both the sitemap and palette omit the same expected post, comparing them only with each other passes; comparing each with the independent expected set fails. This checks membership. Search ranking and the responses behind feed links still need their own checks.

## Trade-offs

We copy the `!draft` filter into each file that reads the collection directly, so even with the preview flag set wrongly we cannot put a draft into the feeds or the JSON list. The cost is that we must add the filter to every new surface, and ask the second question wherever we read through the shared reader. Prefer a shared selector when every consumer needs the same publication policy; where language or preview behaviour differs, keep the differences explicit. We still lack a build in which we compare every surface against one expected set, so the 64-of-64 agreement stays a measurement, not a guarantee.

## Configuration

Table: The versions this note describes, installed on 15 October 2026.

| Package | Version |
|---|---|
| `astro` | 7.2.10 |
| `@astrojs/sitemap` | 3.7.4 |
| `@astrojs/rss` | 4.0.19 |
| `@astrojs/cloudflare` | 14.2.4 |

## Continue in the Astro series

The [homepage case](/blog/our-homepage-is-a-live-system-astro-cloudflare-workers-en/) follows a different handoff, from a server-rendered activity line to browser updates. Next, [how we compare builds and verify a Worker release](/blog/deploying-the-exact-worker-you-tested-en/) explains how to carry a tested output through staging and identify the version serving production.</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Astro</category><category>Content Collections</category><category>Sitemap</category><category>Algolia</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>Old Tabs, New Workers: A Guarded Reload</title><link>https://theeducationalequalityinstitute.org/blog/keeping-open-browser-tabs-working-across-worker-deploys-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/keeping-open-browser-tabs-working-across-worker-deploys-en/</guid><description>We traced a crash to old code reading new loader data, then found our tests expecting a failure report from a reload that worked.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>When we deploy a new Worker, we leave old code running in open tabs. On 15 September 2026 we traced a crash affecting two people to a loader field we had renamed: we were sending new data to old components that still read the old field. In our React Router 8 app on Cloudflare Workers, we now compare the bundle&apos;s release with a header on data responses and request a guarded reload when they differ. Then we found that some of our reports of a failed recovery came from documents we were already replacing. In those, the reload had worked. We had even written that behaviour into our tests.

The app serves TEEI Language and TEEI Mentorship. [How TEEI Runs Its Platforms on Cloudflare Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/) describes the Workers it runs on and how often we deploy them.

**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 a tab keeps, and what changes under it

When someone opens a page, we load the document once, and with it the client bundle, the route manifest and the route modules the tab has visited so far. As the person moves on, we fetch what the tab lacks from the Worker version live now: loader and action data through single fetch, as a request to the same path with `.data` added (`/` becomes `/_.data`, and resource routes are addressed the same way); route modules, as dynamic imports of hashed files from the tab&apos;s own build; and route patches, through [lazy route discovery](https://reactrouter.com/explanation/lazy-route-discovery), the React Router 8 default, which our production builds use.

So the tab runs release A while the answers come from release B, and three things can disagree:

- **A file is gone.** The [Vite build guide](https://vite.dev/guide/build) warns: &quot;When a new deployment occurs, the hosting service may delete the assets from previous deployments.&quot; We have not measured whether our Workers still serve the previous build&apos;s hashed files after a deploy, but on 28 September 2026 we recorded a dynamic import from the previous build failing three seconds after a deploy.
- **The route map is out of date**, which we recognise by React Router&apos;s manifest mismatch error.
- **The data has a new shape.** This was the 15 September crash, and it is the hardest of the three, because it surfaces as a plain `TypeError` that looks exactly like a bug.

Cloudflare names the server-side form of this for [gradual deployments](https://developers.cloudflare.com/workers/versions-and-deployments/gradual-deployments/): &quot;clients and services can end up interacting with more than one version in ways that produce errors or inconsistent behavior. This is called version skew.&quot; An open tab gets version skew from an ordinary deploy, because it carries the old version with it. These error shapes do not by themselves establish skew: on 1 August 2026 we recorded five Safari events in which the current release returned an undefined lazy route module.

## Comparing releases on data responses

A `TypeError` in our own component is indistinguishable from a bug, so we will not reload on the error alone. We have to see the disagreement on the request that carries the data, before the old component renders it.

So we add one header to every loader and action response: the release that answered, a short commit identifier set at build time. The client bundle carries the same commit as a build-time constant, so the tab can compare without a request of its own. On the server, the release also marks an unclean working tree with `-dirty`, which the browser cannot know, so we strip the suffix on both sides and count the value `unknown` as no release at all rather than a different one. We compare normalised commit identities. Builds from the same commit can therefore compare equal even when their working trees differ.

We would have preferred a router hook. In framework mode we render `HydratedRouter`, so there is no `createBrowserRouter` call to give a `dataStrategy` to, and in React Router 8.3.0, we got status, errors and navigation metadata from client instrumentation, but no response headers (we checked on 15 September 2026). So we wrap `window.fetch` in the client entry, before hydration. In the wrapper, we read one header and return the original response; we never read, clone or change the body, and if observation throws, we still return the fetch result. We decide in a pure function; we handle storage, navigation and reporting in the wrapper:

*Adapted for publication.*

```ts
export function decideStaleRelease(input: {
  clientRelease: string | null | undefined;
  serverRelease: string | null | undefined;
  requestUrl: string;
  currentUrl: string;
  hasReloaded: (guardKey: string) =&amp;gt; boolean;
}): StaleReleaseDecision {
  const serverRelease = normaliseRelease(input.serverRelease);
  if (!serverRelease) return { action: &apos;ignore&apos;, reason: &apos;no-server-release&apos; };
  if (!isDataRequest(input.requestUrl, input.currentUrl)) {
    return { action: &apos;ignore&apos;, reason: &apos;not-a-data-request&apos; };
  }
  const clientRelease = normaliseRelease(input.clientRelease);
  if (!clientRelease) return { action: &apos;ignore&apos;, reason: &apos;no-client-release&apos; };
  if (clientRelease === serverRelease) return { action: &apos;ignore&apos;, reason: &apos;same-release&apos; };
  const guardKey = staleReleaseGuardKey(clientRelease, serverRelease);
  if (input.hasReloaded(guardKey)) {
    return { action: &apos;report&apos;, guardKey, tag: STALE_RELEASE_TAG, clientRelease, serverRelease };
  }
  return {
    action: &apos;reload&apos;,
    guardKey,
    url: reloadUrl(input.currentUrl),
    clientRelease,
    serverRelease,
  };
}
```

We compare releases only on same-origin `.data` requests; a missing header is never evidence of skew.

### Reload the page you are on

Our first version, on 15 September 2026, sent the tab to an address worked out from the request it had just seen. We corrected it the same day. Resource routes are `.data` requests too, and our signed-in shell polls one for the unread message count on every page, so the first poll after a deploy would have navigated a person onto a page of raw JSON. We now reload the current document, with its fragment stripped, because `location.assign()` to the same address with a `#fragment` is a same-document navigation that loads nothing. The person loses their in-page position once, and any click in flight.

### Once per pair of releases

We key the guard on the ordered pair of client and server release, kept in `sessionStorage`: healing A to B is a different event from B to A. Before navigating, we write the key and read it back. We treat a tab with no usable storage as one that has already reloaded, and report instead, because a reload loop is worse than a stale tab.

The guard has no time window, with one exception. A page restored from the [back-forward cache](https://web.dev/articles/bfcache) is an older document of the same tab, whose pair a newer document has already spent. When both its [`performance.timeOrigin`](https://developer.mozilla.org/en-US/docs/Web/API/Performance/timeOrigin) and the guard&apos;s time are known and the document is older than the guard, we reload it once more; if either time is unknown, we report. If the tab later meets another new release, that is a new pair and costs one more reload.

## The reload that worked, reported as a failure

When a mismatch survived its reload, we reported it with the two releases and, when the guard could be read, how long ago the reload was spent. We add no request path to that payload.

By 24 September 2026 that report, &quot;client release still differs from the server after one reload&quot;, reached about 117 people a week, according to the commit that changed it that day. The investigation behind the commit looked at the 15 most recent production events. In all 15, the reporting document had been served before the deploy it disagreed with, and in five the same person already had a page on the server&apos;s release within about a second.

In those five, the reload had worked, and the report came from the page on its way out. `location.assign()` is asynchronous: the old document keeps running while the new one is fetched. When we handled its next `.data` response, we found the guard we had just written and reported a persistent mismatch. We had pinned that behaviour as intended in our own test suite, because our fake `assign` never unloaded the page. Five of the 15 inspected events were confirmed to come from a document being replaced successfully. The review does not establish what share of all affected users had recovered.

So since 24 September 2026 we treat the reload as a handover between two documents:

- Once a document has called `assign()`, we make no further reload decision or persisted-mismatch report from its later data responses. Since 30 September 2026 we still record the latest known server release from its data responses first, so an old component that throws on that data before the replacement completes can still be classified.
- If a document is still alive 20 seconds after `assign()`, we report it, because the replacement we asked for has not completed within our budget. Twenty seconds is generous: a slow network keeps the old document alive for seconds. We clear the timer on a [`pageshow`](https://developer.mozilla.org/en-US/docs/Web/API/Window/pageshow_event) event with `persisted` set, the sign of a document back from the back-forward cache.
- An older document restored from the back-forward cache reloads once more, under the conditions above, instead of reporting.

The new tests, run against the previous version of the file, failed five of six. The old test had not checked that the reload worked; it had checked that the departing page complained.

Since 30 September 2026, when a mismatch persists while the document and bundle name the same release and only a data response names another, we send the report as a warning. We interpret this as server disagreement during rollout.

![Sequence diagram. An old document running bundle A sends a data request to a Worker now serving release B. The response carries a release header naming B. We compare A with B in the old document, store the pair in session storage, read it back, and ask the browser to load the current page again without its fragment. The old document keeps running while the replacement loads and receives another data response; we record its release, then return because the document is leaving. A crossed-out grey branch shows the earlier behaviour: we found the pair we had just stored and reported a failed recovery. The replacement document hydrates on bundle B and sends a reload event. A dashed branch shows that if the old document is still alive 20 seconds later, we report that the replacement has not completed.](/images/blog/keeping-open-browser-tabs-working-across-worker-deploys/handover.svg#wide,phone,fit &quot;The reload is a handover. The old document keeps running after it asks for its replacement; we used to read its next response as a failed recovery. The reload event records hydration of the replacement document, not that the person’s action succeeded.&quot;)

Source: TEEI, drawn from our platform code, read on 15 October 2026.

## The errors we still recognise

We compare releases before React Router reads the body, but the old component can still fail while the replacement loads, and other casualties of a deploy only show up as errors. For those we recognise the error itself.

Table: The stale-client shapes our React Router 8 app recognises, with the date each was first handled. The wording is quoted from the browsers and from React Router.

| Shape | What the browser or React Router reports | First handled |
|---|---|---|
| Chunk load failure | Vite&apos;s `vite:preloadError`; at a boundary, `Failed to fetch` (Chrome) or `Load failed` (Safari) | 12 July 2026 |
| Undefined route module | `Cannot read properties of undefined (reading &apos;links&apos;)` (Chrome), `undefined is not an object (evaluating &apos;o.links&apos;)` (Safari) | 13 July 2026 |
| Undecodable data stream | `Unable to decode turbo-stream response` | 16 July 2026 |
| DOM insertion desync | `NotFoundError` from `insertBefore` or `removeChild` | 1 August 2026 |
| Route result mismatch | `No result found for routeId &quot;…&quot;` | 9 August 2026 |
| Route discovery mismatch | `Unable to discover routes due to manifest version mismatch.` | 28 September 2026 |
| Release skew | Any `TypeError` while the tab&apos;s release and the server&apos;s differ | 30 September 2026 |

In six of them we constrain the error type and the wording as narrowly as we can. They classify symptoms; they do not prove that an error came from the framework. DOM desync needs no release mismatch at all: browser translation or an extension can move a node React expects to find, and a reload rebuilds the tree from the server&apos;s HTML, so it shares the guard. Release skew matches by circumstance instead of wording. We use the latest release we have seen on a data response, without tying it to the failing request or component. We gain context without proving the cause, and we give that remembered release no expiry.

One trap is worth knowing if you handle Vite&apos;s preload error yourself. When we prevent the preload error from a rejected import, we receive `undefined`. If we then read `.default`, we raise a second error. A prevented error from preloading one of the import&apos;s dependencies, such as its stylesheet, is different: the import goes on and can succeed.

### One guarded reload per minute

Every framework shape goes through one function. With session storage, we allow one reload per minute per tab; if storage is blocked, we keep the same window on the current history entry (since 22 August 2026), and if neither works, we do not reload. Before reloading, we record the reason. If we do not reload, we return the outcome, and at a route error boundary a refused reload falls through to the normal path: the person sees the error screen, and we report the error with its shape and recovery outcome. A comment in our code says of the undecodable data stream: &quot;A persistent server fault re-renders the boundary after one reload (the guard blocks loops), so nothing real is masked.&quot; That holds for a fault that persists: after one reload, it reaches the error screen and our reports. A real error can still cost one reload first, and once the minute has passed it can trigger another. The guard limits repetition; it does not prove that we classified the cause correctly. We report a network failure that repeats within the minute at low priority and show it as a connection problem, a reading of the error&apos;s shape, not an established cause.

The old document&apos;s analytics queue does not survive a reload, so we write the shape down first, and since 30 July 2026 the next document sends it as a reload event after hydration. That event shows that a document hydrated and consumed the marker. It does not show that the interaction the person started succeeded.

With the release guard above, we count pairs of releases instead of minutes.

![Two columns compare our two guards. With session storage we allow one framework crash reload per minute per tab; if storage is blocked, we keep that window on the current history entry instead. At a route error boundary, we show and report the error when we cannot reload. With the release guard we reload once per pair of releases with no time limit, need session storage and report instead of reloading without it, allow one more reload for a page restored from the back-forward cache only when both timestamps are known and the document predates the guard, keep the leaving document quiet, and report if the replacement we asked for has not completed 20 seconds later.](/images/blog/keeping-open-browser-tabs-working-across-worker-deploys/two-guards.svg#wide,phone,fit &quot;Our two reload guards in the tab: one counts minutes, the other counts pairs of releases.&quot;)

Source: TEEI, drawn from our platform code, read on 15 October 2026.

## React Router&apos;s own version check

The release header is ours. React Router has a version check of its own, and a tab across a deploy can meet both. For React Router&apos;s manifest version, we use its fingerprint of the client entry and route manifest; for our release comparison, we use the build commit. We compile the commit into the client entry, so a new release changes the manifest version too. We compare releases on every data response, while the manifest check runs only when route discovery is needed.

When the versions differ, the `/__manifest` request receives `204` with an `X-Remix-Reload-Document` header, and we rely on React Router 8.4.0&apos;s own handling, which we read in its installed source: during a navigation, we get a document load of the page the tab was heading for, and during a fetcher call, a load of the current page. During background discovery, we get a warning without reloading. We get a document load when a later navigation or fetcher call needs an undiscovered route. If that document has not replaced the page within five seconds, or the same manifest version has already reloaded once, we get React Router&apos;s error `Unable to discover routes due to manifest version mismatch.` Our fix of 28 September 2026 records that this had reached six people, each on a bundle from before a deploy, as a crash over the page. Since then we match that exact sentence and request one guarded reload of the current document under the per-minute guard.

A manifest request can also hang. On 15 September 2026, with React Router 8.3.0, we saw a fetcher action for an undiscovered path enter its submitting state and wait on a `/__manifest` fetch with no time limit, while an interface rendering from `fetcher.json` showed a write as done before its POST had left the browser. We now share one request per URL and allow up to three attempts when the transport or the body read fails, each with a budget covering both; our first version stopped its clock at the headers. We buffer the manifest, clone it per caller and keep the body null for the `204`, so we retain React Router&apos;s handling of its reload header. A caller whose signal aborts stops waiting while the shared request runs on. For loader data we return the original response unread.

## What the tests hold

On 15 October 2026, 168 tests in six files passed. They run against doubles for `window`, `fetch` and `sessionStorage`, not a real browser, and pin React Router&apos;s manifest call, `fetch(url, { signal })`, since our wrapper ignores a `Request`. The properties that matter: each recogniser accepts the wording seen in production and leaves near misses such as `Failed to fetch profile` visible; an unread-count poll after a deploy never shows a person raw JSON; the departing document sends no persisted-mismatch report from later data responses; a document still disagreeing after its reload is reported once for that release pair and not reloaded for it again; and a tab that cannot remember a reload never tries one. We also drive React Router&apos;s installed discovery function: without our wrapper a stalled manifest never settles and an aborted body read fails the navigation; with it, a fresh attempt succeeds. In 8.4.0, the added timer starts after the manifest answers and bounds the document replacement; the fetch itself has only our limit. The 24 September commit records a local browser check we did not repeat.

## When this is the wrong pattern

**A reload throws away what the tab held**: a half-typed message, a scroll position, a click in flight. That is why the release comparison acts only on data requests and only once per pair. If your app holds long-lived client state, a reload on every skew will hurt more than a stale tab.

**A release mismatch classifies; it does not prove.** We treat any `TypeError` as release skew while the releases differ, so a real bug that ships in the same deploy can be reloaded once before it shows. If it recurs inside that minute, we show and report it; a later recurrence can trigger another guarded reload.

**Try the cheaper layers first.** Vite&apos;s guide advises: &quot;make sure to set `Cache-Control: no-cache` on the HTML file, otherwise the old assets will be still referenced.&quot; For gradual rollouts, Cloudflare offers [version affinity](https://developers.cloudflare.com/workers/versions-and-deployments/gradual-deployments/version-affinity/). Neither updates a document that is already open: to move a tab that loaded an hour ago onto a new release, we need a document navigation, or client code in the tab that starts one. TEEI Language and TEEI Mentorship run as two Workers from one codebase, as [Two Workers, One React Router 8 App: Decide at the Boundary](/blog/one-codebase-two-programmes-react-router-8-cloudflare-workers-en/) explains, so a tab compares itself only with the Worker on its own origin.

**What we have not measured.** We have no before and after measurement of stale-tab errors, so we cannot separate the effect of the release comparison from the older shape fixes, and we have not counted how many reloads each shape caused or how often one saved a session. The September figures come from commit messages written on the day, not from a measurement repeated now. We quote our code as it stood on 15 October 2026 and date each measurement.

## Verify this

Reproduce the skew in your own project. Build two versions of a React Router app, have your server or Worker set a release header on its responses, open the first build in a tab, deploy the second, and navigate in the old tab with the network panel open. You will see the old bundle&apos;s `.data` request answered by the new release. The header alone heals nothing: the observer and the reload guard are code you write, and the `decideStaleRelease` excerpt above is a starting point. To see React Router&apos;s own branch, keep lazy route discovery on, deploy a build that changes the route tree while a tab on the old build is open, and follow a link to a route that tab has not discovered: the `/__manifest` request answers `204` with `X-Remix-Reload-Document`.</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>React Router</category><category>Cloudflare Workers</category><category>Vite</category><category>Deployment</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>Astro on Workers: the bugs between build and runtime</title><link>https://theeducationalequalityinstitute.org/blog/migrating-astro-7-on-workers-database-markdown-build-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/migrating-astro-7-on-workers-database-markdown-build-en/</guid><description>We 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.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>The first request returned 500. The second returned 200. After we moved the website of TEEI, a nonprofit, from Astro 5 to 7 on Cloudflare Workers, a cache write could succeed while the request that started it failed: we read a removed API after starting the write. A warm cache hid the broken path.

We also found private `.env` values in the Worker bundle and every opted-in server-rendered route missing from the sitemap. Updating imports was only part of the work. We had to establish when each piece of code runs, which environment supplies its values and which generated files our own tools read. This note follows those boundaries through the failures, fixes and a reproducible build experiment.

**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.

## Plan the migration around execution boundaries

On the website described in [how TEEI runs its platforms on Cloudflare Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/), we replaced the runtime accessors and the database integration, and explicitly selected the Markdown processor that keeps our plugins. Underneath all three, Astro 7 runs on [Vite 8](https://docs.astro.build/en/guides/upgrade-to/v7/), which bundles with Rolldown.

For a similar migration, we would use the following decisions to choose the work and the evidence that closes it. They cross two major upgrades: the runtime accessor and environment changes arrived with Astro 6; the database removal and Markdown default changed in Astro 7.

Table: Migration decisions, grouped by the boundary each one changes.

| Existing dependency | Change to make | What to verify |
|---|---|---|
| `locals.runtime` in a request handler | Read bindings from `cloudflare:workers`, context from `locals.cfContext` and metadata from `request.cf` | A cache miss returns the right response and registers its background work |
| Private values read through `import.meta.env` | Move the reads to runtime; use a rewrite only as a bridge for remaining direct reads | Inspect the generated Worker with a canary value, including object-style reads |
| `@astrojs/db` | Use a database client compatible with workerd and construct it inside the function that needs it | Import-time loading performs no async I/O; client reuse is a separate decision |
| remark or rehype plugins | Install `@astrojs/markdown-remark`; select `unified()` in Astro and configure any direct processor calls too | The rendered HTML and heading metadata still contain the required anchors |
| Prerendering that fails in workerd | Consider `prerenderEnvironment: &apos;node&apos;` | Count the static output and check what imported modules do without Worker bindings |
| Tools that read Astro&apos;s output | Update their format assumptions and execution order | Tests cover the new output and still reject a real violation |

The versions at the end of this note describe our repository and reproduction on 15 October 2026. They do not establish the patch versions deployed in production.

## Request time: a 500 before a cache hit

The adapter&apos;s [upgrade notes for version 13 and Astro 6](https://docs.astro.build/en/guides/integrations-guide/cloudflare/) are short: `Astro.locals.runtime` is gone. Bindings and secrets come from `import { env } from &apos;cloudflare:workers&apos;`, [importable since March 2025](https://developers.cloudflare.com/changelog/post/2025-03-17-importable-env/), request metadata from `Astro.request.cf`, and the execution context from `Astro.locals.cfContext`. What the notes cannot tell you is where your own code still reaches for the old property. With adapter 13 or later, `locals.runtime` is still there as a compatibility object, so code that reads it builds without complaint; we get the exception only at request time, when we read its `env`, `ctx`, `cf` or `caches` property.

On 24 September 2026 we found a surviving read, on our [impact page&apos;s](/about/impact/) published readings, the pinned exports a citation points to. The handler started writing the response to the cache, then read `locals.runtime.ctx` to keep the write alive with `waitUntil`. The read threw &quot;Astro.locals.runtime.ctx has been removed in Astro v6&quot;, so every first request for a reading answered 500, while the cache write had already started and landed. The second request for the same address was a clean hit. Cloudflare counted about 1,000 of those 500s in two hours.

The fix reads the documented replacement:

*Adapted for publication.*

```ts
let put: Promise;
try {
  put = cache.put(request, out.clone()).catch(() =&amp;gt; undefined);
} catch {
  put = Promise.resolve();
}

const ctx = locals?.cfContext;
if (ctx &amp;amp;&amp;amp; typeof ctx.waitUntil === &apos;function&apos;) {
  ctx.waitUntil(put);
} else {
  await put;
}
```

We found the same read in a second reader, where a `try`/`catch` hid the exception: the response survived, but its cache writes ran without `waitUntil`. Our test makes any read of `locals.runtime` throw, which is stricter than the adapter&apos;s property getters, and supplies a working `cfContext`, so any return to the old API fails. The test asserts a 200 response as well as one cache write and one promise passed to `waitUntil`. A test that checked only the cache write would have passed against the original bug, because the exception came after the write had started.

Elsewhere, code that a Node script might also import loads `cloudflare:workers` lazily, inside a `try`, because the module exists only in workerd and a static import would break the script.

## Build time: `import.meta.env` is inlined

The [Astro 6 upgrade guide](https://docs.astro.build/en/guides/upgrade-to/v6/) says it in one line: &quot;`import.meta.env` values are always inlined and never coerced.&quot; Before Astro 6, a private variable read through `import.meta.env` became a reference to `process.env`. From Astro 6 on, it becomes the value. On Workers that matters, because the Worker bundle is built on a machine that has your `.env` file.

On 3 October 2026 we found that private values from `.env`, credentials among them, sat as string literals in the Worker server bundle, the code we deploy as the Worker. The Worker bundle is not served to visitors, and we have not measured how long it carried them. We reproduced the inlining on 15 October in a minimal project with two endpoints.

Use only a synthetic canary value in this local experiment. The example endpoints intentionally return it so the build output can be inspected; do not use real credentials or deploy these endpoints.

One endpoint reads the value through `import.meta.env`:

*Verbatim excerpt.*

```ts
export const prerender = false;
export const GET = () =&amp;gt; new Response(String(import.meta.env.MY_SECRET));
```

The other reads it from the Worker environment:

*Verbatim excerpt.*

```ts
import { env } from &apos;cloudflare:workers&apos;;
export const prerender = false;
export const GET = () =&amp;gt; new Response(String((env as Record).MY_SECRET));
```

With `MY_SECRET=canary-value-7f3a91` in `.env`, Astro 7.2.10 and `@astrojs/cloudflare` 14.2.4, `astro build` turned the first endpoint into this line in a chunk under `dist/server`:

*Verbatim excerpt.*

```js
var GET = () =&amp;gt; new Response(String(&quot;canary-value-7f3a91&quot;));
```

The second endpoint&apos;s chunk imports `cloudflare:workers` and contains no trace of the value. We built it twice, the second time in a clean copy, with the same result.

![Diagram of two paths for a private value from .env. When we read it with import.meta.env, we include the value as a string in the Worker bundle, and we block deployment when our check finds it. When we read it from cloudflare:workers, we keep only a runtime reference in the bundle and read the secret when we handle a request.](/images/blog/migrating-astro-7-on-workers-database-markdown-build/env-paths.svg#wide,phone,fit &quot;Since Astro 6, import.meta.env values are inlined at build time. We read secrets from cloudflare:workers instead and block deployment when our check finds a secret in the bundle.&quot;)

Source: TEEI, drawn from our reproduction and our deploy scripts, 15 October 2026.

There is a second, less obvious way in. When server code reads `import.meta.env` as an object, with bracket access, optional chaining, `typeof`, or by passing it to a helper, Astro injects an `Object.assign(import.meta.env, { ... })` expression containing the private keys named in that module. It finds them by searching the compiled module text for each key name, so any mention of the name counts. When we read the environment as an object, we can embed a private value without ever reading that key directly.

We closed the leak on 3 October in two steps. First, we moved the secret reads in many modules to the Worker environment, read at request time, and the database client factories that needed it became async. We set every name those modules read as a Worker secret before the change went out, because a runtime read of a binding that does not exist returns `undefined`.

Second, for the direct reads that remained, we added a Vite plugin instead of editing each file. In the `ssr` environment, which is the Worker build, it rewrites each direct `import.meta.env.NAME` read of a secret name into a read from `env` of `cloudflare:workers`. Object-style reads are not rewritten. The client build and the Node prerender are untouched: they have no Worker env to read, and we leave public reads for Astro to inline. Our ordinary transform runs on the JavaScript that TypeScript and Astro have already emitted, and before env inlining, because in Astro 7.2.10 Astro places its env plugin immediately before Vite&apos;s define plugin. We find the candidate reads by searching the module text, then hand them to esbuild&apos;s `define`, which replaces only real member expressions. A read that appears inside a string or a comment can enter the candidate set, but esbuild leaves that string or comment unchanged:

*Adapted for publication.*

```js
import { transform } from &apos;esbuild&apos;;

const RUNTIME_ENV_IDENT = &apos;__workerSecretEnv&apos;;
const READ_RE = /\bimport\.meta\.env\.([A-Za-z_$][A-Za-z0-9_$]*)/g;

// isSecret is yours to write: the names that must never be inlined.
export async function rewriteSecretReads(code, id, isSecret) {
  const names = [...new Set([...code.matchAll(READ_RE)].map((m) =&amp;gt; m[1]).filter(isSecret))].sort();
  if (names.length === 0) return null;
  const define = Object.fromEntries(
    names.map((name) =&amp;gt; [`import.meta.env.${name}`, `${RUNTIME_ENV_IDENT}.${name}`]),
  );
  const result = await transform(code, {
    loader: &apos;js&apos;,
    format: &apos;esm&apos;,
    define,
    banner: `import { env as ${RUNTIME_ENV_IDENT} } from &apos;cloudflare:workers&apos;;`,
    sourcefile: id,
    sourcemap: true,
    platform: &apos;neutral&apos;,
  });
  return { code: result.code, map: result.map, names };
}
```

We still write a build-time API in the source, so a reader of a file cannot see that the Worker reads the secret at runtime. That is why we do not trust the source to tell us. Our deploy scripts check the built bundle and refuse one in which they find a secret, and the check has tests of its own. In the staging build recorded on 3 October 2026, it found none.

## Module scope is not request time: the database

The [Astro 7 upgrade guide](https://docs.astro.build/en/guides/upgrade-to/v7/) is plain about it: &quot;The `@astrojs/db` package has been removed in Astro v7.0 and is no longer maintained.&quot; Our database is Turso, which speaks libSQL, so we use the libSQL client itself.

Two details matter on Workers. Import from `@libsql/client/web`, the build that runs in workerd without Node APIs. And do not open the client at module scope: the integration&apos;s top-level `await` did async I/O in the Worker&apos;s global scope, which Workers do not allow. Cloudflare&apos;s [guidance on bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/) goes further: &quot;The correct approach would be to create a new client instance for each request.&quot; That advice bundles two choices, and we keep them apart: deferring construction until a function needs the client, and giving each call a client of its own. A lazy factory that caches its client makes the first choice and not the second. This example makes both:

*Adapted for publication.*

```ts
import { createClient } from &apos;@libsql/client/web&apos;;

export async function readSettings(runtimeEnv?: Record) {
  const url = readEnvString(runtimeEnv, &apos;DB_URL&apos;);
  const authToken = readEnvString(runtimeEnv, &apos;DB_TOKEN&apos;);
  if (!url || !authToken) return defaults;

  const client = createClient({ url, authToken });
  const [mode, banner] = await Promise.all([
    getSettingValue(client, &apos;site.maintenance_mode&apos;, &apos;false&apos;),
    getSettingValue(client, &apos;site.announcement_banner&apos;, &apos;&apos;),
  ]);
  // …
}
```

Whether one client can be shared across requests, and what it costs when it cannot, is a question of its own; [our note on a shared database client across Worker requests](/blog/a-shared-database-client-across-worker-requests-en/) takes it on.

## Markdown: keeping rehype means choosing `unified()`

Astro 7 [renders `.md` and `.mdx` with Sätteri](https://docs.astro.build/en/guides/upgrade-to/v7/), its own Markdown pipeline, &quot;instead of the remark/rehype pipeline&quot;, and `@astrojs/markdown-remark` is no longer installed by default. Markdown still renders without it. What stops running is every remark and rehype plugin in your configuration.

We use `rehype-slug` and `rehype-autolink-headings` for linkable headings, so we installed `@astrojs/markdown-remark` and chose its [`unified()` processor](https://docs.astro.build/en/reference/markdown-processors/unified/) explicitly:

*Adapted for publication.*

```js
markdown: {
  processor: unified({
    rehypePlugins: [
      rehypeSlug,
      [rehypeAutolinkHeadings, { behavior: &apos;wrap&apos;, properties: { className: [&apos;heading-anchor&apos;] } }],
    ],
  }),
},
```

The order of the two plugins matters. The autolink plugin acts only on a heading that already has an ID, and Astro adds its own heading IDs after our plugins have run, too late for autolink, so `rehype-slug` goes first. We have not built the site under Sätteri, so we cannot say what its output or build time would have been for us.

We also call `createMarkdownProcessor()` from the same package directly for our English article template, with the heading plugins listed again, and take both the HTML and the heading metadata from its result. A change to Astro&apos;s `markdown` configuration alone would not reach that second pipeline.

## Prerendering in Node, answering in workerd

Version 13.1.0 of the adapter added [`prerenderEnvironment`](https://docs.astro.build/en/guides/integrations-guide/cloudflare/#prerenderenvironment), `&apos;workerd&apos;` by default or `&apos;node&apos;`. We set `&apos;node&apos;` on 10 May 2026, because prerendering in workerd failed in our build environment with &quot;WebAssembly.instantiate(): Wasm code generation disallowed by embedder&quot;. We found no page in Cloudflare&apos;s documentation for that error, so treat it as what we saw, not as documented behaviour. The build still succeeded; it produced no static HTML.

The price is that the site now runs in two runtimes, and they do not offer the same modules.

![Two columns comparing the prerender step, which runs in Node, with request handling, which runs in workerd. During Node prerendering we use a callable proxy for cloudflare:workers whose calls return undefined; in workerd the module is the real environment with bindings and secrets.](/images/blog/migrating-astro-7-on-workers-database-markdown-build/two-runtimes.svg#wide,phone,fit &quot;With prerenderEnvironment set to ‘node’, we run one site in two runtimes. Modules that read the Worker environment at import time get a stub from us during prerendering.&quot;)

Source: TEEI, drawn from our site configuration and the Cloudflare adapter’s documentation, 15 October 2026.

`cloudflare:workers` exists only in workerd, and many of our files read `env` at module level, so a Node prerender that imported them would fail before it rendered anything. A Vite plugin with `enforce: &apos;pre&apos;` therefore resolves `cloudflare:workers` to a stub, and only in the `prerender` environment; every other environment gets the real module. The stub is a callable proxy: an ordinary binding-name read returns another callable proxy and a call returns `undefined`, so a module can load even when it touches a binding at the top. Its `then` returns `undefined` on purpose; without that, `await` on anything from the stub would treat it as a promise and wait forever. Because we return truthy proxies, we cannot use a binding check during prerendering to establish whether the real binding exists. A page that prerenders without errors proves only that its modules load.

We met the same split in the dev server. On 9 October we found that Astro 7.0.6 invalidates its content data module only in the `ssr` Vite environment when the content store changes. With `prerenderEnvironment: &apos;node&apos;`, pages marked `prerender = true` render in a separate `prerender` environment that kept the data it loaded first, so an edited post kept its old HTML and a new one had no static path until we restarted the dev server. The virtual module has the same ID in both environments, but each keeps its own module graph and its own evaluated copy.

Our dev-only plugin therefore acts in every environment other than `ssr` and `client`, which in our setup means `prerender`, and clears three layers: the module graph, the evaluated module and, through Astro&apos;s content-changed event, the route cache, which in Astro 7.2.10 includes cached `getStaticPaths()` results.

Its test starts a real Vite dev server with a stand-in for Astro&apos;s content plugin that, like Astro 7.0.6, invalidates only `ssr`: without our plugin a test module imported in the `prerender` environment returns the old data, with it the new data.

## The build is Rolldown now, and so is everything that reads it

Vite 8 bundles with Rolldown. Our migration note of 25 June records that Rolldown rejected our old `manualChunks` function on the Worker build, so we removed it that day; chunk grouping for the browser now lives under `vite.environments.client`, as `advancedChunks`, where the [Astro 6 guide](https://docs.astro.build/en/guides/upgrade-to/v6/) moved client output options.

The harder part was our own build. The adapter writes the browser assets, the Worker entry and a generated Wrangler configuration, and we run our own post-build steps after it. One of them exists because the adapter&apos;s entry has no `scheduled` handler, and our Worker has cron triggers; adapter 13.5.0 had no option for a `scheduled` handler when we checked on 11 May 2026. So after the build we rename the adapter&apos;s entry and write a new entry in its place that imports it and adds the `scheduled` handler beside its `fetch`.

![Diagram of our build chain: checks before the build, astro build with its ssr, prerender and client environments, then our steps after the build that prune the sitemap and wrap the adapter entry, and finally the release checks that scan the generated output before deploy. Two marks show where the upgrade broke our own steps: a release check we wrote for the Astro 5 output format, and a sitemap step that ran before we renamed the entry.](/images/blog/migrating-astro-7-on-workers-database-markdown-build/build-chain.svg#wide,phone,fit &quot;Our build and release steps after the move to Astro 7. The release check needed the new output format; the sitemap step needed the original entry name, because it ran before the rename.&quot;)

Source: TEEI, drawn from our build and deploy scripts, 15 October 2026.

Two of our steps broke after the upgrade, and they broke in different ways.

**A format.** Before each deploy, a release check of ours scans the Worker output for routes we have withdrawn. We wrote the check for Astro 5&apos;s manifest format. Astro 7, bundled by Rolldown, no longer writes the manifest in that form; it writes each module as its own region of the bundle. After the upgrade, every Astro 7 bundle we tried to deploy triggered 19 hits in that check for the same withdrawn route, which blocked the deploy meant to retire that route. On 7 October we taught the check the new form, with tests that prove a real violation still fails it.

**An order.** Our sitemap step keeps a server-rendered route in the sitemap only when we have opted that route in and the built Worker contains it. We searched under the new entry name before we renamed the entry, so we removed every opted-in server-rendered route from the sitemap. On 24 September we made the step read the renamed entry when that exists and the original otherwise; the full build that day had all eight opted-in routes in the sitemap. [Keeping content, sitemaps and search in sync](/blog/keeping-astro-content-sitemaps-and-search-in-sync-en/) covers that pipeline.

The lesson for a major upgrade is the same in both: every tool that reads the framework&apos;s output is part of the upgrade, even if its own code did not change.

## Keep compatibility workarounds small and dated

Our rewrite plugin bridges many old `import.meta.env` reads, but it hides the runtime read from someone inspecting the source. On a new project we would read secrets through `cloudflare:workers` or Astro&apos;s [`astro:env`](https://docs.astro.build/en/reference/modules/astro-env/) schema directly. Our Node prerender solved a sandbox failure at the cost of a second runtime, a stub and extra dev-server invalidation. Where workerd prerenders successfully, we would keep the default. We chose `unified()` because we need remark and rehype plugins; a site without them has no such requirement.

Version compatibility needs the same discipline. On 15 October 2026 we moved from Astro 7.0.6 to 7.2.10 and pinned it with a tilde, `~7.2.10`. Astro 7.3.0 removed `beginContentEntryCollection`, which `@astrojs/cloudflare` 14.2.0 to 14.2.4 imports, and the adapter&apos;s peer range, `^7.2.0`, still permits npm to install 7.3. With 7.3.8 and adapter 14.2.4, our build failed with `MISSING_EXPORT`.

The adapter that supports Astro 7.3 is 14.2.5 or later and requires Wrangler 4.125 or later. Our upgrade note also pairs that change with newer Workers types and a newer Sentry SDK; those are choices in our upgrade plan, rather than additional adapter peer requirements. The pin keeps us off every 7.3 fix until we do that work. A pin therefore needs a recorded reason and an exit condition.

For the update within Astro 7, we did compare builds: on 15 October we built the same source with 7.0.6 and adapter 14.1.1, then with 7.2.10 and adapter 14.2.4. The generated pages differed only in per-build noise such as random IDs and timestamps, and no value from the local secrets file reached the Worker entry, its chunks or the browser assets. That establishes a result for those two builds. We have no before-and-after measurement of build time, bundle size or page performance for the move from Astro 5 to 7.

## Verify this

You can reproduce the inlining in your own project. Nothing here calls TEEI&apos;s systems.

1. Create an Astro 7 project with `npm i astro@7.2.10 @astrojs/cloudflare@14.2.4`. We ran it on Node 22.23.2; other versions may behave differently. The whole configuration we used:

   *Adapted for publication.*

   ```js
   // astro.config.mjs
   import { defineConfig } from &apos;astro/config&apos;;
   import cloudflare from &apos;@astrojs/cloudflare&apos;;
   export default defineConfig({ output: &apos;server&apos;, adapter: cloudflare() });
   ```

   *Adapted for publication.*

   ```jsonc
   // wrangler.jsonc
   { &quot;name&quot;: &quot;repro&quot;, &quot;compatibility_date&quot;: &quot;2026-01-01&quot;, &quot;compatibility_flags&quot;: [&quot;nodejs_compat&quot;] }
   ```
2. Put `MY_SECRET=canary-value` in `.env`.
3. Add the two endpoints from the section on build time: one that returns `import.meta.env.MY_SECRET`, one that returns `env.MY_SECRET` from `cloudflare:workers`.
4. Run `npx astro build`, then `grep -rl canary-value dist`.

Expected, from our runs on 15 October 2026: both endpoint chunks exist. The first contains the value as a string literal; the second imports `cloudflare:workers`, reads `env.MY_SECRET` and does not contain the value. `dist/client` does not contain it either. The adapter also writes it to `dist/server/.dev.vars` for local development; we did not test whether `wrangler deploy` uploads that file, so check your own deploy.

This experiment tests the inlining mechanism independently of our site. The historical failures and regression-test descriptions come from our private repository; they are not results you can reproduce by calling a TEEI endpoint.

## Appendix: versions

The versions in our repository on 15 October 2026, read from `node_modules` and matching the lockfile:

Table: The framework and tool versions behind the excerpts and the reproduction, 15 October 2026.

| Package | Version |
|---|---|
| `astro` | 7.2.10 (pinned `~7.2.10`) |
| `@astrojs/cloudflare` | 14.2.4 |
| `@astrojs/markdown-remark` | 7.3.2 |
| `@libsql/client` | 0.15.15 |
| `vite` | 8.1.3 |
| `esbuild` | 0.28.2 |
| `wrangler` | 4.107.0 |
| `rehype-slug`, `rehype-autolink-headings` | 6.0.0, 7.1.0 |
| Node | 22.23.2 |

## Continue in the Astro series

The [homepage case](/blog/our-homepage-is-a-live-system-astro-cloudflare-workers-en/) follows these runtime boundaries through one visible activity line. Next, [keeping content, sitemaps and search in sync](/blog/keeping-astro-content-sitemaps-and-search-in-sync-en/) follows a post through the outputs whose publication rules also have to survive a migration.</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Astro</category><category>Cloudflare Workers</category><category>libSQL</category><category>Vite</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>Pages to Workers: The Defaults We Had to Rebuild</title><link>https://theeducationalequalityinstitute.org/blog/moving-a-multi-app-platform-from-pages-to-workers-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/moving-a-multi-app-platform-from-pages-to-workers-en/</guid><description>We moved three apps from Pages to Workers, then changed Cloudflare accounts. We had to rebuild defaults for headers, redirects and deployment targets.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>We began moving three apps from [Cloudflare Pages](https://developers.cloudflare.com/pages/) to Workers in May 2026, then moved [our Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/) to a new Cloudflare account. Our records date four separate steps: a first Workers deploy of the website beside its Pages project on 9 May, with no traffic moved; the admin&apos;s DNS cutover on 14 May; our domains becoming active on the new account on 28 and 29 June; and the deletion of the website&apos;s Pages project on 4 September. We have not found the day the website&apos;s apex traffic moved. We lost headers and redirects that had depended on Pages defaults. Wrangler reported our secrets as saved, yet the running Worker read them as empty. We had to follow each request to the layer that answered it and inspect the configuration we actually deploy. For our Astro website, we select the deployment environment after the build; for our React Router apps, we select it during the build. We met three of the same traps again when we changed accounts.

We had to make Pages defaults explicit in the layers that answer requests and in the configuration we deploy to each target. If you are moving a Pages app or changing Cloudflare accounts, these are the defaults to check.

**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 a Pages convention means on a Worker

A Worker with [static assets](https://developers.cloudflare.com/workers/static-assets/) looks like Pages from outside: it serves files from a directory, runs code and sits behind the same edge. Cloudflare&apos;s [migration guide](https://developers.cloudflare.com/workers/static-assets/migration-guides/migrate-from-pages/) lists the mechanical steps: deploy with `wrangler deploy`, point `assets.directory` at the build output, add an `.assetsignore` for `_worker.js`, and build any `functions/` folder with `wrangler pages functions build`. They cannot tell you which of your app&apos;s behaviours depended on a Pages default. For us, these did.

Table: Four Pages conventions and what each means on a Worker with static assets. Quotations from Cloudflare’s documentation, read 15 October 2026.

| Pages convention | On a Worker with static assets | What we did |
|---|---|---|
| `_headers` | Applied to static assets only; &quot;not applied to responses generated by your Worker code&quot; ([headers](https://developers.cloudflare.com/workers/static-assets/headers/)) | Middleware, plus a static copy of the content security policy for prerendered pages |
| `_redirects` | &quot;Not applied to requests served by your Worker code&quot; ([redirects](https://developers.cloudflare.com/workers/static-assets/redirects/)) | Zone rules and the Worker |
| Functions and `_routes.json` | Pages ran Functions ahead of assets; Workers serves assets first unless `assets.run_worker_first` says otherwise, and does not use `_routes.json` | The website runs its Worker first, by choice |
| `wrangler pages deploy` | `wrangler deploy` reads a configuration the build generates | We check and merge the generated file before each deploy |

## The first deploy: what the website lost

The three apps that left Pages were our website, the admin our staff work in and [Evenhall](/partners/evenhall/), all Astro apps at the time. On 9 May 2026 we deployed the website as a Worker next to its Pages project, without moving any traffic; its apex pointed straight at Pages, with no router Worker in front. We have found the date of the parallel deploy, but not the date apex traffic moved. That first deploy showed us five gaps.

1. **`_headers` was parsed, and refused.** One rule was longer than the 2,000 characters per line that the [limits page](https://developers.cloudflare.com/workers/platform/limits/) allows, next to a cap of 100 rules (Wrangler reported error 10021, a code we take from our report, not from documentation).
2. **Headers have different scopes.** Pages and Workers apply header configuration at different response layers, so we configure headers in the layer that serves each response.
3. **`_redirects` stopped working.** None of its 549 lines, 51 of them wildcard rules, were honoured on the Worker path.
4. **The new Worker had no secrets.** A Worker is a new service, so every secret had to be created on it again.
5. **Schedules could have fired twice.** Pages has no cron; on Workers, a `scheduled()` handler wraps our built Worker. Its configuration enabled native cron triggers while an external scheduler still called the same jobs, and our report warned that both would run them unless the external scheduler was switched off first.

For the first gap we took the oversized file out of the output and added an `.assetsignore`, which keeps the Worker code and the Pages control files out of the asset upload. Workers still applies the `_headers` rules, because Wrangler reads the file directly, and the [headers page](https://developers.cloudflare.com/workers/static-assets/headers/) says the file &quot;will not itself be served as a static asset&quot;. What the ignore file protects is the Worker code, which would otherwise sit in the output as a file anyone could fetch. Our build deletes the output folder first, so `.assetsignore` lives in Astro&apos;s public folder, which the build copies in.

## Worker first, by choice

Cloudflare&apos;s migration guide states the difference in default order: &quot;Pages would default to serving your Pages Functions ahead of static assets ... Workers, on the other hand, will default to serving static assets ahead of your Worker script, unless you have configured `assets.run_worker_first`.&quot; This is the block in the website&apos;s configuration:

*Adapted for publication.*

```toml
[assets]
directory = &quot;./dist&quot;
binding = &quot;ASSETS&quot;
run_worker_first = true
not_found_handling = &quot;404-page&quot;
```

Until 15 October 2026, the comment above this block said that Astro server-side rendering required the setting. Our reproduction for [Worker First Is Not Middleware First: Astro on Cloudflare](/blog/worker-first-or-assets-first-routing-astro-on-cloudflare-en/) showed otherwise, and we corrected the comment the same day. With `run_worker_first = false`, the result depends on the compatibility date and the navigation settings. At our date of 2024-09-23, an on-demand route rendered through the Worker and its middleware. At 2025-04-02 with the default navigation flags, a browser navigation to that route got the 404 page without the Worker running, while the same request without `Sec-Fetch-Mode: navigate` reached both. Cloudflare [documents](https://developers.cloudflare.com/workers/configuration/compatibility-flags/) that navigation preference as the default from 1 April 2025, and an explicit compatibility flag can change it. The appendix gives the setup.

We chose `true` because of our Worker entry. It answers redirects and other rules before Astro runs, and we want it to see every request before the asset layer does, files and prerendered pages included. The price is that every request, static files included, runs the Worker, and the migration guide&apos;s footnote says middleware set up this way &quot;is charged as a normal Worker invocation&quot;. With Smart Placement, asset requests must also first travel to the placed Worker, which Cloudflare&apos;s [routing](https://developers.cloudflare.com/workers/static-assets/routing/worker-script/) and [binding](https://developers.cloudflare.com/workers/static-assets/binding/) pages say may increase asset latency. Our May report does not weigh either cost. Our React Router apps (admin, Evenhall, TEEI Language and Mentorship) keep the Workers default: assets first, the Worker for everything else.

## Headers: one policy, two delivery paths

The headers page says it plainly: &quot;If you use a server-side rendered (SSR) framework, have configured `assets.run_worker_first`, or otherwise use a Worker script, you will likely need to attach any custom headers you wish to apply directly within that Worker script.&quot; We did that on 10 May 2026, moving our security headers into Astro middleware.

That covered only part of the site. The Worker runs for every request on our website, but with our versions and a prerendered 404 page, Astro middleware runs only for on-demand routes: not for files, not for prerendered pages and not for paths with no route. Those responses come from the static assets, where the `_headers` file applies. So the website keeps one definition of its content security policy, in middleware, and two ways of delivering it. On every build we read the policy out of the middleware and write a static copy into `_headers` for the prerendered pages. Nobody edits the copy by hand.

![Diagram of one request passing three layers on our website, with the versions we tested and a prerendered 404 page. Zone redirect rules answer first with a 301 when a rule matches. Every other request reaches the Worker entry first, because we set run_worker_first to true; redirects in the entry answer there, for files and prerendered pages too. On-demand routes get their security headers from Astro middleware; prerendered pages, files and paths with no route go on to the static-asset layer, where the _headers rules apply and middleware does not run. A side box shows one content security policy in middleware feeding both paths, through a static copy the build writes into _headers.](/images/blog/moving-a-multi-app-platform-from-pages-to-workers/who-answers.svg#wide,phone,fit &quot;One policy, two delivery paths. For the website’s tested versions and prerendered 404 page, middleware covers on-demand routes; the static copy in _headers covers what the asset layer serves.&quot;)

Source: TEEI, drawn from our website code and Cloudflare&apos;s documentation on 16 October 2026.

The copy has to obey the 2,000-character line limit, and a content security policy can outgrow it. The documentation gives the way out: &quot;If a header is applied twice in the `_headers` file, the values are joined with a comma separator.&quot; A comma-joined content security policy is a list of policies, and a browser enforces every one of them, so a request is allowed only if all of the policies allow it. Cutting the policy into pieces naively would therefore narrow what it allows.

We keep the fallback directive in the first policy and move the longest other directive out until that policy fits. For each fetch directive we move, we add a permissive stand-in to the first policy, because only fetch directives fall back to the fallback directive, and we stop with an error if a stand-in would not shorten the policy. We put each moved directive into the first later policy with room, and none of those carries the fallback: in our evaluator, a later policy with neither a directive nor a fallback for a fetch type imposes no restriction on it.

We test that the split keeps every original directive byte for byte and that every policy fits on one `_headers` line with the header name. Within our evaluator&apos;s model, we compare URL decisions before and after splitting, fallback included, for scheme-and-host source tokens, wildcard subdomains and suffix lookalikes. We use two negative controls: without the stand-ins, the split blocks a URL the original allowed, and the evaluator must reject the suffix lookalike.

We reject duplicate directive names, a missing fallback directive and any single directive too long for the line. We read the policy only as a flat list of string literals, one per line, and reject comments, variables, templates and spreads. We also refuse a hand-written policy in any other header rule, so a second matching rule cannot silently intersect with the generated one.

## Redirects: in front of the Worker, not inside the build

A Worker can answer redirects itself, and zone rules can answer them before the Worker runs; we now use both. We needed the zone rule because our middleware never saw some paths in a production build. On 25 July 2026 we traced why some locale-prefixed city pages did not redirect. Astro&apos;s i18n fallback had emitted one meta-refresh stub per locale per city page, 12,115 files in all, which pushed the client output past 20,000 files (the [limit](https://developers.cloudflare.com/workers/platform/limits/) per Worker version is 20,000 on Free and 100,000 on Workers Paid). We prune those stubs at build time now. The middleware rule that was meant to answer with a 301 never fires in a production build, because Astro&apos;s locale routing answers those paths with the 404 page before user middleware runs; on the dev server the same rule does fire. A [Single Redirect](https://developers.cloudflare.com/rules/url-forwarding/single-redirects/) at the zone runs before the Worker, so it is the layer that can answer. On 12 October 2026 we added a second zone rule for English blog posts reached through a Ukrainian or Norwegian prefix, whose stubs had answered 200 with a meta refresh rather than a redirect status.

Anyone can check that rule, measured here on 15 October 2026 at 16:08 UTC:

*Adapted for publication.*

```bash
curl -sI &quot;https://theeducationalequalityinstitute.org/uk/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/?ref=check&quot;
# HTTP/2 301
# location: https://theeducationalequalityinstitute.org/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/?ref=check
```

The destination answered 200. The `server: cloudflare` header does not tell you whether a Worker produced the response.

We generate the redirects that need application knowledge into our Worker entry, which answers before Astro runs, so they cover files and prerendered pages too, which middleware never sees; middleware handles the rest. We describe the changes to [Astro&apos;s build-time redirects](/blog/why-we-moved-astro-redirects-out-of-the-build-en/) in a related note.

## Generated configuration: one environment, chosen at different stages

Wrangler deploys the configuration our build generates, not the one in the repository. On the website, the Astro adapter (`@astrojs/cloudflare` 14.2.4) writes a fresh `wrangler.json` into the build output on every build, and `wrangler deploy --config` reads that file. The build can omit a source setting, so a check returns an error when the generated file lacks `run_worker_first`, because `/sitemap.xml` must reach the Worker for its 308 to `/sitemap-index.xml`; a second check looks for that alias in the generated Worker entry. Both read the build output and send no request.

The adapter may copy top-level `vars` into the generated file, but it does not carry environment blocks through. So before each deploy, our scripts merge in production values or, for staging, the named environment. We build that environment&apos;s vars from the source root vars overlaid by its own, an inheritance our merge step supplies and Wrangler does not. We render a complete environment block rather than flattening staging into the top level, because Wrangler does not inherit vars or bindings between environments: a flattened configuration deployed with `--env staging` would leave staging without its intended KV and R2 bindings. In that block we keep the adapter&apos;s asset directory, relative to the generated file rather than the repository root, and test it with the unchanged production name and vars.

The merge also carries keys that exist only in the source configuration, such as `workers_dev`, which &quot;Defaults to `true` when the configuration has no `route` or `routes`&quot; ([Wrangler configuration](https://developers.cloudflare.com/workers/wrangler/configuration/)). Since Wrangler 4.44.0 [preview URLs follow it](https://developers.cloudflare.com/changelog/post/2025-10-23-preview-url-default-behavior/), and a subdomain turned off in the dashboard but not in Wrangler [comes back on the next deploy](https://developers.cloudflare.com/workers/configuration/routing/workers-dev/). We pin `workers_dev = false` and `preview_urls = false` for production, and the merge carries them into the file Wrangler reads. We test that the merge copies those settings and rejects malformed ones, and that it keeps staging schedules empty, so a staging deploy never runs the production schedule.

We select the environment for our React Router apps during the build. Our admin decision log from the spike on 9 May 2026 records four rules, each found the hard way.

- **Build with the environment set.** The Cloudflare Vite plugin reads `CLOUDFLARE_ENV` at build time to choose which `env.` block it flattens into the generated configuration ([Vite plugin environments](https://developers.cloudflare.com/workers/vite-plugin/reference/cloudflare-environments/)). A plain build drops the staging block, and `wrangler deploy --env staging` afterwards does not fix it; in our log&apos;s words, &quot;the build artefact wins&quot;.
- **Declare bindings in every environment.** Top-level `kv_namespaces`, `vars` and `version_metadata` are not inherited into an environment block, so a staging build deployed with no KV bindings until we declared them in each environment.
- **Give `wrangler secret put` the environment the deploy uses.** With `--name` alone, Wrangler wrote admin&apos;s secrets to a different service environment from the one our staging deploy read. It reported &quot;Success!&quot;, `wrangler secret list` showed the secrets as set, and the running Worker read every one of them as empty. We only found out by reading the values from inside the running Worker, and we had to pass both `--name` and `--env`. The rule is to match the target, not the flags: for [TEEI Language](/programmes/education/teei-language/) and [TEEI Mentorship](/programmes/education/teei-mentorship/), we build each environment from one React Router 8 codebase as its own Worker name, so there `--name` with that name is the right target.
- **An empty var beats a secret.** A `vars` entry with an empty string overrides a secret of the same name, and our error-reporting SDK initialised and then sent nothing. A related refusal came on the production deploy on 10 May 2026: a secret upload was rejected with code 10053 while a plain-text binding of the same name existed, so the order became deploy first, then upload the secret.

Shipping exactly the build you tested, environment included, is the subject of [How We Compare Builds and Verify a Worker Release](/blog/deploying-the-exact-worker-you-tested-en/).

## Admin and Evenhall: a proxy in front, one prefix at a time

We rewrote admin and Evenhall from Astro on Pages to React Router on Workers. On 9 May 2026 we decided, for admin, to &quot;Deploy to Cloudflare Workers (not Pages)&quot; and to move by the strangler-fig pattern. A small proxy Worker took over the hostname and held a list of path prefixes that had moved. A prefix matches its exact path or a path that begins with the prefix followed by a slash, and the root prefix is an explicit catch-all, so adding it was the final routing change. We route matching paths to the new React Router Worker and all other paths to the old Pages app. Removing a prefix from the list was the rollback, and our Evenhall document puts its propagation at about 60 seconds.

We moved admin section by section between 10 and 13 May and switched its catch-all for `/` on 14 May 2026, when the migration log records &quot;CUTOVER COMPLETE&quot;: we pointed the CNAME straight at the Worker, so the proxy was no longer in the path, detached the custom domain from the Pages project and added a Worker route on the zone. Evenhall followed the same pattern with its own proxy; its operations document says its Astro stack &quot;was retired on 2026-05-10&quot;, and we have not found the day its last prefix moved. One Evenhall detail is worth borrowing: we bound the new Worker to the same KV namespace the Pages project had used, because &quot;Production KV id matches the existing Pages binding so counters keep their state across the cutover.&quot;

The proxy cost one more Worker, a change in the old app so both halves agreed on the signed-in person, and a period with two apps answering one hostname. A direct DNS switch is simpler when the app is not being rewritten. The website went without a proxy; the May report does not say why.

## Worker to Worker: public fetch or a service binding

On 9 May 2026 a `fetch` from one of our Workers to another through its public `workers.dev` address answered 404 at the edge, first in the admin migration and again when we tried to move Evenhall&apos;s `/api/*` prefix. We replaced the admin call with a service binding, rolled the Evenhall switch back, and had its service-binding version working on staging the same day.

The [custom domains page](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) gives the rule: &quot;On the same zone, the only way for a Worker to communicate with another Worker running on a route, or on a workers.dev subdomain, is via service bindings&quot;, unless the target runs on a Custom Domain, which accepts a public `fetch` directly. For a target on a zone route, a global `fetch` on the same zone needs the `global_fetch_strictly_public` compatibility flag, and the [errors page](https://developers.cloudflare.com/workers/observability/errors/) lists error 1042 for one without it; a service binding needs no flag. How we connect Workers now is in [fetch bindings and RPC entrypoints between our Workers](/blog/worker-to-worker-communication-service-bindings-rpc-en/).

## Between accounts: identifiers, data and activation

Three of May&apos;s traps came back when we changed accounts: a new Worker starts with no secrets, enabling both schedules risks running a job twice, and the configuration that deploys is the one the build generated, not the one in the repository. Our four domains became active on the new account on 28 and 29 June 2026 (our overview has the [timeline of those two days](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/)). We had to remap binding identifiers separately from moving data.

**Identifiers.** In the runbook, we identified two kinds of binding identifier to remap: the ID of each KV namespace and the ID of each D1 database. The runbook required us to swap them in a copy of each configuration, leaving the repository configuration unchanged while the old account still served production. R2 and Vectorize bindings keep their bucket or index names, so the same configuration finds them on the new account as long as something with that name exists there. Durable Object bindings keep their class names. The new account needed its own `workers.dev` subdomain before the first deploy. The React Router apps had to be deployed from the configuration their build generated; bundling one from its source configuration failed on the virtual server-build module. Unlike Pages, Workers supports custom domains only on nameservers Cloudflare manages, which all ours were.

**Data.** Binding by name is not a state-transfer mechanism. The runbook kept the remap apart from the data move: R2 data was already copied and byte-verified before the Workers moved, and KV and D1 needed a separate sync close to the switch. The buckets skipped during the data copy still had to exist on the new account, so we created those empty buckets before binding them. Vectorize indexes had to be created again and filled again from the source content; the runbook notes that their vectors do not export cleanly. For the Durable Objects in that move, the runbook classified the state as ephemeral and instructed us to redeploy the classes.

**Activation.** A secret that only lived in Cloudflare cannot be read back, so each had to be found at its source or created again. Once secrets were installed, schedules on both accounts would target the same database, so the runbook required cron triggers to stay off on the new account until cutover. It also required disabling Logpush and removing tail consumers for the first deploys.

## What stays behind

Pages still does the job for a static site with no server-rendered routes, no cron, no Durable Objects and no Logpush, but Cloudflare [advises](https://developers.cloudflare.com/pages/migrations/): &quot;Start new projects with Workers.&quot; A move leaves the old project in place, and nothing reminds you to remove it. The migration guide ends with the step: once production traffic has moved, delete the Pages project in the dashboard or with `npx wrangler pages project delete`. We deleted the website&apos;s Pages project on 4 September 2026, four months after the first Worker deploy. Put the deletion in the plan with a date, the same way the cutover has one.

## What we cannot tell you

We cannot tell you whether Workers is faster. We have no measurement of performance or cost before and after the move. The move overlapped with upgrades from Astro 5 to 7, new adapter versions and the new account, so a difference could not be attributed to Workers even if we had measured one. Git history for May 2026 is thin in our repositories, so we draw the migration dates, sizes and findings from internal reports that readers cannot inspect, and we say where a date is missing. We read Cloudflare&apos;s documentation on 15 October 2026.

## Appendix: the reproduction and the routing settings

The reproduction ran locally with `wrangler dev` on 15 October 2026: Astro 7.2.10, `@astrojs/cloudflare` 14.2.4, Wrangler 4.107.0, `nodejs_compat` as the only compatibility flag and `not_found_handling = &quot;404-page&quot;`. We sent each request with and without `Sec-Fetch-Mode: navigate` and read a marker header from the Worker and one from the middleware.

Table: Routing settings for the website and for TEEI Language and TEEI Mentorship, read in our repositories on 15 October 2026.

| Setting | Website | TEEI Language and Mentorship |
|---|---|---|
| `compatibility_date` | `2024-09-23` | `2024-11-01` |
| `compatibility_flags` | `nodejs_compat_v2`, `nodejs_als` | `nodejs_compat` |
| Assets binding | `ASSETS`, `run_worker_first = true`, `not_found_handling = &quot;404-page&quot;` | `ASSETS`, assets first |
| `workers_dev` in production | `false` (with `preview_urls = false`) | `false` |</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Cloudflare Workers</category><category>Cloudflare Pages</category><category>Static Assets</category><category>Migration</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>Two Workers, One React Router 8 App: Decide at the Boundary</title><link>https://theeducationalequalityinstitute.org/blog/one-codebase-two-programmes-react-router-8-cloudflare-workers-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/one-codebase-two-programmes-react-router-8-cloudflare-workers-en/</guid><description>We build two Workers from one React Router app, pass programme identity through typed context, and use stored rows for scheduled work.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>We run TEEI Language and TEEI Mentorship from one [React Router 8](https://reactrouter.com/) source tree. We share booking, video, messages and a database, but build and deploy each product as a separate Cloudflare Worker. We still need the right programme for every request. If we get it wrong, we could show a learner the wrong product or file a booking under the wrong programme. We resolve the programme at the Worker boundary and pass it to loaders and actions through typed context. For scheduled work, we take the programme from the stored row.

Using the same request context, we cut session lookups on three signed-in Language pages from three per navigation to one. That is a count of calls in our development notes from 31 July 2026, not a production benchmark. In counting the calls, we found a loader nobody had listed. Underneath it all, the build target, the programme of a request and the programme stored on a record answer different questions, and we keep them apart. [How TEEI Runs Its Platforms on Cloudflare Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/) tells the story from the account down: the Workers, their bindings, their placement next to the database and how we deploy them.

**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.

## Two products, two builds

[TEEI Language](https://language.theeducationalequalityinstitute.org/) helps adult learners practise a language through one-to-one conversations with approved volunteers and through group sessions. [TEEI Mentorship](https://mentorship.theeducationalequalityinstitute.org/) connects a person seeking career guidance with an approved volunteer mentor who has relevant professional experience. [Skills Academy](https://skills.theeducationalequalityinstitute.org/), where volunteers teach workshops, clinics and classes, is a programme of its own on the same platform, served on its own hostname by the Mentorship Worker. We follow the rule in our platform documentation: &quot;The engine is shared. Each programme module decides whether a mechanism is available, how it is described, which data makes a profile listable, and which programme-specific effects may run.&quot;

![Diagram: one React Router 8 source tree is built twice, once for TEEI Language and once for TEEI Mentorship. Each build becomes its own Cloudflare Worker with its own presence storage. The Mentorship Worker also serves Skills Academy. Both Workers use one database with programme-scoped rows.](/images/blog/one-codebase-two-programmes-react-router-8-cloudflare-workers/one-tree-two-builds.svg#wide,phone,fit &quot;One tree, two builds. We build each Worker separately and fix its Cloudflare environment at build time.&quot;)

Source: TEEI, drawn from our platform code and Wrangler configuration, read on 15 October 2026.

React Router 8 has no `adapter` option, so we integrate through the [Cloudflare Vite plugin](https://developers.cloudflare.com/workers/vite-plugin/reference/cloudflare-environments/) and a Worker entry of our own. One build variable names the target, Language or Mentorship. From it we pick the matching Wrangler file and a separate output directory, so a build of one target cannot overwrite the other&apos;s artefact in the middle of a deploy. We serialise builds of the same target, because its staging and production builds share that directory, and we reject an ordinary deploy build unless its target is exactly one of the names we know. We never fall back to Language.

We choose the Cloudflare environment with `CLOUDFLARE_ENV` before building; we cannot switch it when deploying the artefact. Cloudflare&apos;s documentation says that &quot;specifying `CLOUDFLARE_ENV` when running `vite preview` or `wrangler deploy` will have no effect&quot;, because the build writes an [output configuration](https://developers.cloudflare.com/workers/vite-plugin/reference/migrating-from-wrangler-dev/) that, in the words of [Cloudflare&apos;s changelog](https://developers.cloudflare.com/changelog/post/2025-11-09-cloudflare-env-variable/), is &quot;flattened to only contain the active environment&quot;. So we choose the environment in the build script and nowhere else, we treat an unknown environment as fatal rather than picking a default, and we never promote a staging build to production: production gets a build of its own.

The two Workers share the database but not their live presence. We give each Worker its own namespace of the `LivePresence` Durable Object; [Cloudflare&apos;s documentation on Durable Objects and environments](https://developers.cloudflare.com/durable-objects/reference/environments/) notes that a binding attached to a different Worker name &quot;will therefore access different Durable Objects with different persistent storage&quot;. Marking a volunteer online on Language does not mark them online on Mentorship. The [Durable Object article in this series](/blog/real-time-availability-with-durable-objects-en/) covers what that object does.

## The programme is decided at the Worker boundary

![Flow diagram of a request in the TEEI Language and Mentorship Workers. At the Worker entry, we resolve the programme, return 404 if we cannot, and run the request checks. We handle a live-presence WebSocket upgrade on a separate branch. For other requests, we apply the feature flags, build the typed router context and call React Router. Inside the app, we read programmeContext where needed. We then add security and release headers to the response. Direct cron dispatch uses the same handler with the programme set to stored-row.](/images/blog/one-codebase-two-programmes-react-router-8-cloudflare-workers/request-path.svg#wide,phone,fit &quot;We decide the programme before React Router runs. Inside the app it is a typed context value, never a guess.&quot;)

Source: TEEI, drawn from our platform code, read on 15 October 2026.

We resolve the programme and run the request checks before calling React Router, then add the response headers; figure 2 shows the full order. In production we take the programme from the hostname, and the step fails closed. A Worker that has no valid programme scope does not serve at all, and a request whose programme we cannot resolve gets a 404, not a guess:

*Adapted for publication.*

```ts
let requestProgramme: &apos;language&apos; | &apos;mentorship&apos;;
try {
  requireWorkerProgrammeScope(runtimeEnv);
  requestProgramme = resolveProgramme(request, runtimeEnv).key;
} catch {
  logger.warn(&apos;request.programme_scope_rejected&apos;, { method: request.method, path: logPath });
  return completeResponse(new Response(&apos;Not Found&apos;, { status: 404 }));
}
```

Once we know the programme, we build a new context for the request&apos;s middleware, loaders and actions. With React Router 8&apos;s middleware, which we enabled on 31 July 2026 and which became the default when we moved to 8.3.0 the next day, `context` is a [`RouterContextProvider`](https://raw.githubusercontent.com/remix-run/react-router/main/docs/api/utils/RouterContextProvider.md), not a plain object:

*Verbatim excerpt.*

```ts
const requestHandler = createRequestHandler(
  () =&amp;gt; import(&apos;virtual:react-router/server-build&apos;),
  import.meta.env.MODE,
);

function buildRouterContext(
  env: Env,
  ctx: ExecutionContext,
  logger: Logger,
  cspNonce: string,
  programme: &apos;language&apos; | &apos;mentorship&apos; | &apos;stored-row&apos;,
  requestCountry: string | null = null,
): RouterContextProvider {
  const rrContext = new RouterContextProvider();
  rrContext.set(cloudflareContext, { env, ctx });
  rrContext.set(loggerContext, logger);
  rrContext.set(securityContext, { cspNonce });
  rrContext.set(programmeContext, programme);
  rrContext.set(requestGeoContext, { country: requestCountry });
  return rrContext;
}
```

We keep the context keys in one small module that imports nothing at runtime except `react-router`. If it imported application code at runtime, it could bring a cycle into the Worker entry&apos;s module graph and cause a module-initialisation failure at deploy time; as a leaf, it cannot start that cycle. We import the application types the contexts carry with `import type`, to keep their contracts without introducing runtime dependencies. The Worker and the application share the exported keys, and each request&apos;s values live in the new provider we create for that request. The provider indexes values by the context object itself, so a second `createContext` call with the same TypeScript type would create a different key.

We keep three things apart: the Worker that serves a hostname, the runtime programme identity a request runs under, and the product surface a person sees. Skills Academy is its own programme on the same platform; in the code described here, its hostname is served by the Mentorship Worker, uses the Mentorship runtime identity and shows a Skills surface with its own name and words. A hostname of its own does not make Skills a third runtime identity: every request runs as Language or Mentorship. Skills does appear as a programme on stored group sessions, a separate value from the programme of the request that reads them. For the Language and Mentorship runtime identities, we define what each offers in one descriptor: a taxonomy, its session types, and capabilities from one shared vocabulary, such as `direct-booking`, `group-sessions`, `self-service-signup` and `credentials`. We use the same capabilities to decide whether the interface shows a button and, in a server guard, whether the operation may run. Only the second is a boundary; in the words of our documentation, &quot;UI visibility is not the security boundary.&quot;

Both production builds read one explicit route table, not routes discovered from files. The same path can mean different things on the two hostnames, and we decide in the server guard whether a route does anything for this programme.

The programme is set in context at the boundary, but it is not resolved only once. The root loader, the portal shell and the code that tags an error report with its programme resolve it again from the same request, and the last of those taught us the lesson in the next section.

## Scheduled work uses the programme on the row

A scheduled job has no visitor and no meaningful hostname, so it cannot ask the request which programme it belongs to. Our platform rule says it should not try: &quot;Background work must use the programme stored on the booking, session, delivery or account row. An internal cron hostname must never decide a persisted record&apos;s programme.&quot;

For the cron routes we dispatch directly through React Router, we set `programmeContext` to `&apos;stored-row&apos;`, a value that cannot be mistaken for Language or Mentorship. Its type, `ProgrammeKey | &apos;stored-row&apos;`, means we must narrow it before passing it as a programme, and the job code must still take that programme from the stored record.

Where a job runs says nothing about its programme either. Only the Language Worker has cron triggers, so we schedule some Mentorship work there, including the Skills Academy jobs, and send it through a service binding to the Mentorship Worker, as the [parent article](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/) describes. That decides where the code runs, not which programme a record belongs to.

Our incident notes from 23 July 2026 show what happens when a scheduled path asks the request anyway. A scheduled reminder job failed, and we then tried to tag the failure with a programme for the error report. The scheduled request carries no product hostname, so the strict resolver threw a second time inside the error handler, and the report that reached us named a programme error instead of what had actually broken. We now resolve the programme for the tag in a `try` and give up quietly: a tag must never decide whether a request can be handled, and the strict resolution that governs access still runs where access is decided.

## Middleware that only fills a cache

Our portal shell is the layout around most signed-in pages. Before 31 July 2026, the shell loader and each child loader looked up the session for themselves, and the root loader did too, to choose the language of the page. That day we moved the lookup into the shell&apos;s middleware, which resolves the session once per server request and puts it in `sessionContext`:

*Adapted for publication.*

```ts
// Resolve the session ONCE per server request for the whole matched branch.
// The middleware only CACHES (sets sessionContext); every redirect decision
// stays in the loaders and guards that own it, and guards without a context
// argument keep resolving for themselves.
export const middleware: Route.MiddlewareFunction[] = [
  async ({ request, context }) =&amp;gt; {
    const env = getRuntimeEnv(context);
    const session = await resolveSession(request, env);
    context.set(sessionContext, session ?? null);
  },
];
```

We distinguish three states: unresolved, resolved with no session, and resolved with a session. That is why the session context has a symbol default:

*Adapted for publication.*

```ts
/** The session resolved ONCE by the portal shell&apos;s middleware. Three states:
 *  SESSION_UNRESOLVED (the DEFAULT: no middleware resolved it, e.g. a resource route
 *  outside the shell, so guards fall back to their own lookup), `null` (middleware
 *  ran and there IS no session), or the session. */
export const SESSION_UNRESOLVED: unique symbol = Symbol(&apos;session-unresolved&apos;);
export const sessionContext = createContext(
  SESSION_UNRESOLVED,
);
```

With [`createContext`](https://raw.githubusercontent.com/remix-run/react-router/main/docs/api/utils/createContext.md), an explicit `undefined` is no default in React Router 8.4.0: reading an unset context with `context.get` throws. The first version of our cache test failed on exactly that, on every route the middleware did not cover. When the sign-in guard reads a cached `null`, we redirect to sign in without another lookup, and only the unresolved marker sends the guard through its original lookup. Our regression test preloads a session and requires the guard to return that exact object; an untouched provider and the older shape of test context must still take the original path.

We counted session lookups on three signed-in Language pages: the learner home, the list of volunteers and the bookings page. Our notes from 31 July 2026 record three lookups per navigation before the change, from the root loader for the page&apos;s language, the shell&apos;s guard and the child page&apos;s guard, and one after it, from the middleware. The count found the last holdout: the root loader, which was not on our list to convert. We have not measured the change against production since, and we have not measured how much time it saves per navigation.

The decision we would make again is that the middleware only fills a cache. Redirect decisions stay in the loaders and guards that own them, and a guard that never receives `context` still behaves as before. We introduced the typed context and the session cache as separate changes on 31 July 2026, and we can remove the middleware without moving any redirect decision.

Server middleware runs &quot;for HTML Document requests and `.data` requests for subsequent navigations and fetcher calls&quot;, says the [middleware documentation](https://raw.githubusercontent.com/remix-run/react-router/main/docs/how-to/middleware.md). When a document POST continues from action to loaders, React Router 8.4.0 builds a new `Request` but passes the same context. We create that context per server request at the Worker entry, so a cached session never crosses requests, and a navigation without a server request has nothing to fill.

## What one codebase costs

Our platform documentation states the price as plainly as the rule: &quot;The two Workers are built separately from the same tree. A change in shared code can therefore affect both products even when only one product motivated the change.&quot;

**Client catalogue size.** We offer six interface languages on Language and four on Mentorship, and the translation catalogues travel with the client code. On 15 October 2026 at 11:18 UTC, the Language sign-in page referred to 50 JavaScript files. We fetched each file on its own with curl: 2,024,340 bytes uncompressed in total, and 509,814 bytes downloaded when we asked for gzip. The largest was the English catalogue, 859,245 bytes uncompressed and 236,980 with gzip. Those are the files the HTML refers to, fetched one by one, not a recording of what a browser transferred before it could hydrate. Our build budgets, listed in the appendix, count what the build ships, uncompressed, not what the first page loads.

**We kept `splitRouteModules` off.** In React Router 8.3.0 the transform that splits route modules lost source maps, which could have pointed error reports at the wrong source line. Our configuration notes record a clean production A/B build on 2 August 2026 that showed what we gave up: the client catalogue would have gone from 4,591.0 KB to 4,582.6 KB, and the entry module and the largest lazy chunk did not change. We still load route chunks lazily.

**When this is the wrong pattern.** If two products share a login screen and little else, two codebases are simpler. Even when they share a data model, we can choose when to deploy each Worker, but a shared change still has to be checked against both products. And if you cannot afford a build per target, the build-time choice of environment will cost you: every target is a full build, and a build for staging cannot become production.

Streaming this app under a strict Content Security Policy has [an article of its own](/blog/streaming-react-router-under-a-strict-content-security-policy-en/).



## Verify this

- **The same reported release on three hostnames.** Send `curl -sI` to `https://language.theeducationalequalityinstitute.org/login`, to the same path on the `mentorship.` and `skills.` hosts. Each answer carries `x-lc-release`, the release header we set on every response the Worker secures: the short commit the build came from, with `-dirty` added when build-relevant files differed from that commit. On 15 October 2026 at 11:18 UTC all three carried the same value, from two separately built Workers. A matching header shows the same reported commit, not the same binary artefact: each Worker is its own build.
- **The undefined default, in your own project.** In a React Router 8 project, compare `createContext(undefined)` with `createContext(Symbol(&apos;unset&apos;))`. Read each with `context.get` before setting either: the first throws; the second returns its symbol.
- **Internal only.** The session lookup count, the 23 July incident and the build budgets come from our own repository and notes. You cannot check them from outside; we report them as we recorded them.

## Appendix: configuration

Table: Versions and platform settings for TEEI Language and TEEI Mentorship, read on 15 October 2026. The code in this article is the code in our repository in mid-October 2026.

| Item | Value |
|---|---|
| `react-router`, `@react-router/dev`, `@react-router/cloudflare` | 8.4.0 |
| `react`, `react-dom` | 19.3.0 |
| `vite` | 8.1.4 |
| `wrangler` | 4.144.0 |
| Router config | `ssr: true`, `serverModuleFormat: &apos;esm&apos;`, `prerender: false`, `splitRouteModules: false`, lazy route discovery |
| Production Workers built from the tree | 2 (Language; Mentorship, which also serves Skills Academy) |
| Durable Object binding | `LIVE_PRESENCE`, class `LivePresence`, migration tag `v1`, one namespace per Worker |
| Cron triggers | 3 on the Language Worker, none on the Mentorship Worker |
| Client JavaScript ceiling | 12,100,000 bytes, client JavaScript files in the asset folder, manifest excepted |
| Entry module ceiling | 260,000 bytes |</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>React Router</category><category>Cloudflare Workers</category><category>Middleware</category><category>Architecture</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>How We Share One Session Lookup Across React Router Loaders</title><link>https://theeducationalequalityinstitute.org/blog/one-session-lookup-per-navigation-react-router-middleware-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/one-session-lookup-per-navigation-react-router-middleware-en/</guid><description>We found a session lookup in the language helper, outside our list of guards. We moved resolution into layout middleware and gave the context three states.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>We expected two session lookups per request. We found three.

Most signed-in pages on TEEI Language and TEEI Mentorship, [one React Router 8 app built into two Workers](/blog/one-codebase-two-programmes-react-router-8-cloudflare-workers-en/), sit under one layout route, which we call the portal shell. In our plan of 31 July 2026 we listed the two places that looked up the viewer&apos;s session on such a page: the shell&apos;s guard and the child page&apos;s guard. When we counted, the root loader looked it up as well, to choose the language of the page. It was not on our list because it did not look like a guard.

Each lookup made network calls to the one database [our Cloudflare Workers platform](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/) runs on, a Turso primary in AWS Europe (Ireland). So we moved the lookup into [a React Router middleware](https://reactrouter.com/how-to/middleware) on the shell and shared the result through a typed router context. We resolve the session in the child layout&apos;s middleware before even the root loader starts, so one lookup can serve all three readers. On three signed-in pages in a development build, we counted three lookups before the change and one after. We did not measure latency.

To reuse the result, the context has to tell &quot;we have not checked&quot; apart from &quot;we checked and nobody is signed in&quot;. We describe where the lookup happens and how the rest of the request reads it, not what a session is or how it is checked.

**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 we counted

We instrumented the function that resolves the session and counted how often each request resolved it, recording nothing about who was signed in. On 31 July 2026 we opened three signed-in pages of TEEI Language: the learner home, the list of volunteers and the bookings page. Before the change, each of those requests resolved the session three times. After it, each resolved it once, in the middleware.

We counted lookups, not database statements; our commit message records the count, and we found no raw log lines in our repository. We have not repeated the count, counted production requests or measured latency. We cannot isolate this change&apos;s effect on page time from later changes to how our Workers reach the database without a new measurement, and we have not made one.

## Middleware runs before every loader

We resolve the session before any loader starts. React Router&apos;s middleware chain runs &quot;from parent routes to child routes on the way &quot;down&quot; to your route handlers&quot;, and the [route module reference](https://reactrouter.com/start/framework/route-module) says where the loaders come in: &quot;on the leaf route the `next` function executes the loaders/actions for the navigation.&quot; So the downward part of the chain, every middleware on the matched branch up to its `next()`, has run before any loader on that branch starts.

The root loader is one of those loaders. Our root route has no middleware of its own, but by the time its loader runs, the shell&apos;s middleware below it has already resolved the session for this request. The loaders do not wait for each other: in React Router 8.4.0 the default data strategy starts the loaders it has selected together with `Promise.all`. We rely on middleware order, not on a parent loader finishing first.

![Two sequence diagrams of one server request. Before: the root loader, the shell guard and the child page guard each look up the session, three lookups. After: the shell middleware looks it up once and stores it in a router context, and all three loaders read it from there.](/images/blog/one-session-lookup-per-navigation-react-router-middleware/request.svg#wide,phone,fit &quot;One server request under the portal shell, before and after 31 July 2026. Counted on a development build on three pages on 31 July 2026, in the case where the root loader also needs the session; each arrow is one session lookup, not one database statement. Not measured in production.&quot;)

Source: TEEI, drawn from our platform code and our commit record of 31 July 2026.

Middleware runs per server request, not per navigation. Server middleware runs &quot;for HTML Document requests and `.data` requests for subsequent navigations and fetcher calls&quot;, and &quot;Client-side navigations will only run server middleware if a `.data` request is made to the server&quot;. So the shell&apos;s middleware resolves the session once for each document or `.data` request that matches the shell branch, and a client-side navigation that needs no server data costs no lookup. We infer one run per request from the middleware chain and the rule that &quot;You can only call `next()` once per middleware.&quot;

The cache lives in the router context, and at the Worker entry we create a new `RouterContextProvider` for every request we pass to React Router. Nothing about the session is kept in module scope. Cloudflare&apos;s [Workers best practices](https://developers.cloudflare.com/workers/best-practices/workers-best-practices/#do-not-store-request-scoped-state-in-global-scope) say why that matters: &quot;Workers reuse isolates across requests. A variable set during one request is still present during the next.&quot; Keeping the current viewer in module scope could let one request reuse another person&apos;s session.

We turned middleware on through React Router 7&apos;s future flag on the evening of this change; it has been always on since we moved to React Router 8.3.0 the next day, and the code we show runs on 8.4.0.

## A context with three states

We declare the session cache once, in a small module that both the Worker entry and the application import:

*Adapted for publication.*

```ts
// Default: no middleware has resolved the session for this request.
export const SESSION_UNRESOLVED: unique symbol = Symbol(&apos;session-unresolved&apos;);
export const sessionContext = createContext(
  SESSION_UNRESOLVED,
);
```

We import the same context object everywhere because the provider uses object identity as its key. A second `createContext` call creates another slot, even with the same type and default value.

We needed a distinct, defined default, and we chose a symbol. The [`createContext` reference](https://reactrouter.com/api/utils/createContext) says that without a default, &quot;reading this context when no value has been set will throw an error.&quot; It does not say what an explicit `undefined` does. In React Router 8.4.0, `get` returns the default only when it is not `undefined`, and otherwise throws &quot;No value found for context&quot;. We read that in the source, and we had already met it in a failing test: our first cache test used an `undefined` default and threw as soon as it read a provider the middleware had not touched.

Neither `context.get` nor our fallback lookups store a result, so the context stays unresolved, and a second reader that falls back repeats the lookup. For a guard, the states mean:

- **`SESSION_UNRESOLVED`**: no session result has been stored in this provider. The route is outside the shell, or the caller is a test. Look it up yourself.
- **`null`**: the middleware ran, and nobody is signed in. Do not look again; send the person to sign in.
- **a session**: use it.

The provider checks for a stored value before it reads the default, so a stored `null` stays a result. With the obvious type, `Session | null` with a `null` default, an unset context reads as `null` too. A guard could not tell &quot;nobody looked&quot; from &quot;we looked and nobody is there&quot;, so it would either repeat the lookup we were removing or redirect a request that was never checked.

![State diagram of the session context. The default is an unresolved marker, which tells a guard to look the session up itself. In the middleware, we replace the marker with null when nobody is signed in, or with the session.](/images/blog/one-session-lookup-per-navigation-react-router-middleware/states.svg#wide,phone,fit &quot;Three states, because for a guard “nobody looked” and “we looked and nobody is there” call for different behaviour.&quot;)

Source: TEEI, drawn from our platform code, read on 15 October 2026.

## The middleware fills the cache, the guards decide

*Adapted for publication.*

```ts
// Resolve the session once per server request for the whole matched branch.
// Redirect decisions stay in the guards.
export const middleware: Route.MiddlewareFunction[] = [
  async ({ request, context }) =&amp;gt; {
    const env = getRuntimeEnv(context);
    const session = await resolveSession(request, env);
    context.set(sessionContext, session ?? null);
  },
];
```

We fill the context without calling `next()` or returning a response; the documentation&apos;s own example notes that &quot;next() is called automatically&quot;. Our middleware does other work too, which we left out of the excerpt. It never redirects.

Every guard takes the router context as an optional argument and reads it first:

*Adapted for publication.*

```ts
export async function requireSession(request, env, context?) {
  if (context &amp;amp;&amp;amp; isRouterContextProvider(context)) {
    const cached = context.get(sessionContext);
    if (cached !== SESSION_UNRESOLVED) {
      if (!cached) throw signInRedirect(request);
      return cached;
    }
  }
  return lookUpSessionOrRedirect(request, env);
}
```

We shortened the example without changing its branches. The role guards built on `requireSession` pass the same `context` on. Without a usable context, or with `SESSION_UNRESOLVED`, a guard looks the session up itself. A cached `null` goes straight to the redirect, without a second lookup. Because the argument is optional, we could adopt the cache route by route.

The third lookup needed the same read. In the root loader, we choose the language of the page from four sources in order: a language in the address, a language the browser already stored for us, the language saved on the signed-in account, and the browser&apos;s `Accept-Language` header. Only the third needs the session, so the root lookup happens only on requests where neither the address nor the stored language has decided. Our record of the count does not say which case it ran with, only that the root lookup was observed. In the root loader, we pass `context` to the language helper. There, we read `sessionContext` first, look up the session only for `SESSION_UNRESOLVED`, and skip the account language for `null`. Callers that pass no context, such as several API routes outside the shell, keep their own lookup.

Opt-in makes the gain depend on coverage. When we went through our route modules on 15 October 2026, all but four of the calls to the session and role guards passed `context`, and those four sit in API routes outside the shell, where the context would hold `SESSION_UNRESOLVED` anyway. That says how many calls are wired to the cache. It does not say how many lookups a page makes.

## The tests that guard it

We test the cache without a database or mocks. Every request in the test carries nothing that could identify anyone, so the real lookup can only end in a redirect. If the guard returns a session anyway, the only place it can have come from is the context:

*Adapted for publication.*

```ts
it(&apos;returns the middleware-resolved session without any lookup&apos;, async () =&amp;gt; {
  const ctx = new RouterContextProvider();
  ctx.set(sessionContext, SESSION);
  await expect(requireSession(request(), undefined, ctx)).resolves.toBe(SESSION);
});
```

`SESSION` is a synthetic fixture. Other tests in the same file check that a context holding `null` throws a redirect to the sign-in page, and that a provider the middleware never touched falls through to the real lookup. The `null` test checks the redirect; that no second lookup happens is something the code shows, not something that test proves. A test of the language helper puts a session with a saved language in the context and expects that language with zero session reads. Its name says what it proves: &quot;uses the portal session cache without another database lookup&quot;.

In a targeted search on 15 October 2026, we found no test that counts session lookups for a whole request. The tests prove that the cache is used when it is there. If a new route forgot to pass `context` to its guard, or a new helper in the root loader looked the session up on its own, they would still pass. Our only check for that is a count like the one on 31 July.

## When this is the wrong pattern

**When your lookup is cheap.** The pattern earns its keep because each lookup makes network calls to one database in one region. If resolving the viewer makes no network call in your application, the cache saves little, and every guard still has to follow the contract. We resolve the session eagerly for every request through this layout; placing the middleware higher in the route tree makes the lookup run for more requests, including ones that never needed it.

**When you cannot keep coverage up.** We kept existing calls working by making `context` optional. A guard called without it repeats the lookup and returns the same type, so typechecking cannot catch that regression, and we have found no test that would.

**When you want the middleware to decide.** We left redirects out of this step on purpose and kept that move for a separate decision, which we have not made. A middleware that redirects decides for every request that passes through that layout. We keep redirect decisions in the existing guards; for middleware errors we rely on React Router, which handles an error a middleware throws &quot;at the appropriate `ErrorBoundary`&quot;.

**When you expect fewer lookups across requests.** The cache is per request by design. A document request followed by three fetcher requests to routes under the shell runs this middleware four times. A document POST, a form submitted without JavaScript, is one request: whichever loaders run after its action run inside the same middleware pass and share this context and its one lookup, even though React Router hands them a `Request` rebuilt from the URL, headers and signal. That is why we key the cache to the context and not to the `Request`. When a client-side submission reaches a server action, the action travels in its own `.data` request; if server loaders then revalidate, that is a separate `.data` request, and under the shell it gets a new context and another lookup.

**When you cannot afford two context shapes.** Hundreds of our route tests pass an older, loose context object, so our helpers recognise a `RouterContextProvider` by its `get` method and otherwise read the old shape. Maintaining both is a continuing cost.

## Verify this

You can reproduce the mechanism in your own project. We have not run this exact recipe; we base it on the documentation and the behaviour described here. In a React Router 8 app, add a layout route with two child routes and one route outside the layout, each with a server loader. Declare `createContext(UNRESOLVED)`, where `UNRESOLVED` is a symbol. In the layout&apos;s `middleware`, increment a counter, then `context.set` a fake session. In the root loader and all three route loaders, read it with `context.get`.

1. Load the first child route in a fresh tab. The counter goes up by one, and both loaders see the session.
2. Load the route outside the layout. Its loader reads `UNRESOLVED` and does not throw.
3. Change the default to `createContext(undefined)` and load the route outside the layout again. In React Router 8.4.0, `context.get` should now throw.
4. Restore the symbol default and load the first child route again. Follow a client-side link to the second child route under the same layout, and watch its `.data` request in your browser&apos;s network panel. The counter goes up by one for that request. A client-side navigation that makes no `.data` request leaves it where it was.

The code we show is the code in our repository on 15 October 2026 unless we give another date.</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>React Router</category><category>Middleware</category><category>Cloudflare Workers</category><category>Performance</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>Building a Live Homepage with Astro and Cloudflare Workers</title><link>https://theeducationalequalityinstitute.org/blog/our-homepage-is-a-live-system-astro-cloudflare-workers-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/our-homepage-is-a-live-system-astro-cloudflare-workers-en/</guid><description>A local browser experiment found two ordering bugs in our activity line. We follow the handoffs between Astro, Workers and the visible text.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>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 TEEI. The activity feed describes completed sessions on the shared platform behind [TEEI Language](/programmes/education/teei-language/) and [TEEI Mentorship](/programmes/education/teei-mentorship/), two programmes with different purposes. Our [Cloudflare Workers overview](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/) 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&apos;s `server:defer`](https://docs.astro.build/en/guides/server-islands/) 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](https://docs.astro.build/en/guides/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&apos;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

```

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](/blog/worker-first-or-assets-first-routing-astro-on-cloudflare-en/) separates Cloudflare&apos;s decision from the adapter&apos;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&apos;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](/blog/public-live-activity-feed-without-participant-identities-en/) 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](/blog/static-pages-live-data-astro-server-islands-cloudflare-workers-en/) 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.](/images/blog/our-homepage-is-a-live-system-astro-cloudflare-workers/website-architecture.svg#wide,phone,fit &quot;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.&quot;)

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&apos;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&apos;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 &amp;lt;= 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: Three branches of the local response-copy experiment. Each successful branch exchanged `hello`, `ping` and `pong` and closed with code 1000.

| Return path | Observed result |
|---|---|
| `new Response(response.body, response)` | The upgrade completed |
| Named status and headers, without `webSocket` | Construction threw; no upgrade |
| Named fields including `webSocket: response.webSocket` | The 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&apos;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](/blog/websockets-through-astro-at-the-worker-boundary-en/) 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&apos;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.](/images/blog/our-homepage-is-a-live-system-astro-cloudflare-workers/reading-age.svg#wide,phone,fit &quot;Illustrative ordering for the examined reader. These are separate cache clocks, not measured intervals or a calculated maximum data age.&quot;)

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](/blog/static-pages-live-data-astro-server-islands-cloudflare-workers-en/) includes the reader, its projection and the dated production captures.

## Bring related reads together deliberately

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&apos;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: Browser observations from the original and proposed clients, using synthetic records. The durations identify records; they are not performance measurements.

| Case | Original client | Proposed local variant |
|---|---|---|
| Fragment paused | Empty initial position; no socket | Same |
| Empty ledger, then a new event | Fallback appears; socket event replaces it | Same |
| Filled ledger, then a new event | Retained record appears; newer event replaces it | Same |
| Storage older than server record | 20-minute record → older 10-minute record → 20-minute record after `hello` | Keeps the 20-minute record |
| Storage newer than server record | Newer stored record is replaced by older `hello` | Keeps the newer stored record |
| Older event delivered after newest | 30-minute record → older 10-minute record | Keeps the 30-minute record |
| Duplicate or different event at equal time | Rewrites the line and dispatches again | Keeps the displayed record; no new write or dispatch |
| Fragment returns 503 | Empty position and no socket after 1.8 seconds | Same |

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](/downloads/astro-live-line-reproduction.zip). 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&apos;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&apos;s screen.

The local experiment exercises the rendered fragment, client and synthetic event together. It does not exercise the programme platform&apos;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&apos;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.

</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Astro</category><category>Cloudflare Workers</category><category>Durable Objects</category><category>WebSockets</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>We Mask Country Pairs Before Storing Live Events</title><link>https://theeducationalequalityinstitute.org/blog/public-live-activity-feed-without-participant-identities-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/public-live-activity-feed-without-participant-identities-en/</guid><description>We omit names, coarsen times and mask country pairs before storage, then derive every public view from that record and remove its id at runtime.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>For TEEI&apos;s public feed of completed conversations, we omit names and coarsen times and durations. Someone who knows a participant&apos;s circumstances may still recognise them from a country pair, a settlement time and a duration when few people take part. We therefore decide what geography to keep before we store an event in a Durable Object. Since 14 October 2026, we keep both countries only when the latest counts we hold put each at 20 or more active participants over the last 90 days; otherwise, we replace both with UN sub-regions. We derive every public view from that stored event. We have not measured whether the resulting feed prevents recognition.

On our home page, as built on 14 and 15 October 2026, one such event becomes a line like &quot;Verified 4 min ago · TEEI Language · Norway ↔ Ukraine · 30 min on video&quot; (the countries and figures here are an example, not a session). When we checked production on 15 October 2026, the line did not yet appear there. Below we follow one event from a session settled in [TEEI Language](/programmes/education/teei-language/) or [TEEI Mentorship](/programmes/education/teei-mentorship/), through the website&apos;s Worker, to the browser. The platforms behind it are described in [How TEEI Runs Its Platforms on Cloudflare Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/), the lead of this reading path.

**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.

## Why a country can be an identity

If only two volunteers in a country take part, a line that names that country, the minute a session was settled and its length may help someone who knows their circumstances infer which of the two took part. Over a week, those times could also suggest that participant&apos;s routine.

We have changed our mind on this once. On 22 July 2026 we took country pairs out of TEEI&apos;s public statistics altogether and published aggregates only. On 14 October 2026 we changed the design to bring country pairs back under the threshold, and decided to apply it in the Durable Object that stores the feed rather than in the page that shows it.

## What the sender leaves out

We create the event in sender code that the Language and Mentorship Workers share. A session counts as completed only when the platform holds verified proof of attendance. We write the settlement and queue its [outbox](/blog/reliable-email-cloudflare-queues-transactional-outbox-en/) effect in the same database batch, and we perform the website post later from the dispatcher, so a failed post cannot undo that batch.

The body contains an opaque event id, the programme, the country on each person&apos;s profile as an ISO 3166-1 alpha-2 code, the minutes and the minute the session was settled. It contains no name, no email, no city, no account id and no booking id. The volunteer is `from` and the learner is `to`, and we leave out a profile country we cannot map to a code. We skip sessions where either side is one of our synthetic test people before anything is posted.

As of 15 October 2026, we take the minutes from the time both people were connected to the video, not from the booked length, round them to the nearest five with a minimum of five, and cut the settlement time to the minute. This is coarsening, not anonymisation.

We leave out how the request is authenticated. What matters for the design is delivery: as of 15 October 2026, we make at most five attempts from the outbox, so delivery is bounded and not guaranteed, and the same event can arrive more than once. We suppress repeats at the receiver; we do not recover an event that never arrives.

## Why we discard unknown keys

On the website&apos;s Worker, we validate each event with a zod object schema before it reaches the Durable Object. The detail worth copying is what the schema lacks: `.strict()`. We discard undeclared keys instead of rejecting an otherwise valid event, and we pin that behaviour in a test. If a sender change one day adds a name or a city to the body by mistake, we keep the known fields and drop the accidental addition; with a strict schema, we would reject the whole event.

We run the same parser before forwarding the event and again inside the object, and we forward the parsed event rather than the original request body, so we do not carry discarded keys across that boundary.

## Masking before storage

On the website, each event passes three steps: we validate it, build a masked record and remove the record&apos;s id before publication. We build the masked record in a pure function, which we test as ordinary TypeScript with a local region table and no Durable Object API:

*Verbatim excerpt.*

```ts
/** True when both codes are known and both meet the threshold. */
export function pairMeetsThreshold(
  from: string | undefined,
  to: string | undefined,
  active: ActiveCountries,
  threshold: number
): boolean {
  if (!from || !to) return false;
  if (!subregionOf(from) || !subregionOf(to)) return false;
  return (active[from] ?? 0) &amp;gt;= threshold &amp;amp;&amp;amp; (active[to] ?? 0) &amp;gt;= threshold;
}

function place(code: string | undefined, showCountry: boolean): LivePlace | undefined {
  const region = subregionOf(code);
  if (!region) return undefined;
  return { country: showCountry ? (code as string) : null, region };
}

/** The event as the Durable Object stores and broadcasts it: masked before anything else. */
export function maskLiveEvent(event: LiveEvent, active: ActiveCountries, threshold: number): StoredLiveEvent {
  const showPair = pairMeetsThreshold(event.from, event.to, active, threshold);
  const stored: StoredLiveEvent = {
    id: event.id,
    type: event.type,
    programme: event.programme,
    minutes: event.minutes,
    outcome: event.outcome,
    settledAt: event.settledAt,
  };
  const from = place(event.from, showPair);
  const to = place(event.to, showPair);
  if (from) stored.from = from;
  if (to) stored.to = to;
  return stored;
}
```

We calculate `showPair` once and pass it to both `place` calls, so one country below the threshold masks both sides. A country missing from the counts counts as zero, so when the count map is empty, we mask both countries. An event with only one known side shows that side&apos;s region only, because a pair needs two. We build the output field by field and never copy the raw `from` and `to` strings, so a stored place is `{ country, region }`, `{ country: null, region }` or absent.

We map codes to UN M49 sub-regions such as Northern Europe and Eastern Europe. For an unknown code we return no region, and the event then carries no place for that side. As of 15 October 2026, the threshold is a constant of 20 in the Durable Object, which we pass into the function; we neither expose it to the page nor allow the page to override it.

That is the point of the design: a country removed before storage cannot reappear in any view of this stored feed.

![The same sample event in three states. As sent, it carries an opaque id, the programme, two country codes, 30 minutes and the settlement minute, and no person. As stored, the countries become objects with a region; with 20 or more active participants in both countries the country codes stay, with 19 in one of them both become null. As sent to browsers, the event is the stored one without its id.](/images/blog/public-live-activity-feed-without-participant-identities/three-states.svg#wide,phone,fit &quot;One sample event, sent, stored and published. The countries and figures are a test fixture, not a session.&quot;)

Source: TEEI, the live feed&apos;s code as of 15 October 2026.

## Where the counts come from

As of 15 October 2026, we count active volunteer records and learner accounts per profile country with `COUNT(DISTINCT ...)`, so repeated sessions do not raise a country&apos;s count. Each needs a verified completed one-to-one session whose booked end falls within the last 90 days, and we exclude sessions with synthetic test people. We map country names to ISO codes, adding counts that resolve to the same code, and fetch the result over HTTP from the platform&apos;s public statistics, linked in the lead note, like any other client.

As of 15 October 2026, we cache the counts in memory and in the object&apos;s storage. In the constructor, we restore the stored copy inside `blockConcurrencyWhile`, so no event is handled before the cache is back. We reuse the counts for one hour after a successful read and five minutes after a failed one, both measured from the start of the attempt; after that, the next incoming event can trigger a fresh read. When we have no country counts, we mask every pair.

## One object, one projection

The receiver is a single Durable Object for the whole site that stores the masked events and supplies every public view, and we keep the events in its [SQLite storage](https://developers.cloudflare.com/durable-objects/api/sqlite-storage-api/). In its ingest method, we parse and mask each incoming event, then write it to SQL and broadcast its public form. As of 15 October 2026, we keep the newest 200 events. We suppress repeats against retained records; bounded history and concurrent arrivals do not establish exactly-once publication or display.

The id is the only stored field that does not leave the object, and we send every outgoing copy through one function:

*Verbatim excerpt.*

```ts
export function toPublicView(event: StoredLiveEvent): PublicLiveEvent {
  const { id: _id, ...publicEvent } = event;
  return publicEvent;
}
```

We cannot rely on the type alone to exclude the id. `PublicLiveEvent` is `Omit`, but `Omit` deletes nothing at runtime, and a `StoredLiveEvent` stays assignable to `PublicLiveEvent` with its id intact, so we remove the id at runtime. We apply that projection to the HTTP snapshot, the socket&apos;s opening `hello` and every broadcast.

The HTTP snapshot and the socket&apos;s opening `hello` share a bounded public history of recent events, and the socket then receives each new event. We accept sockets through the [WebSocket Hibernation API](https://developers.cloudflare.com/durable-objects/best-practices/websockets/) with a `live` tag and ask the runtime for the tagged sockets on each broadcast, so the loop does not depend on an in-memory connection array. We answer the upgrade in the Worker entry and return the object&apos;s response directly, so the 101 response never passes through Astro.

We let the runtime answer a browser&apos;s `ping` with `pong`, which the [State API documentation](https://developers.cloudflare.com/durable-objects/api/state/) describes as happening without waking a hibernating object. On 15 October 2026 at 10:47 UTC, we sent `ping` on the production socket from our test client and received `pong`. We never send `pong` from our handler, so we received the runtime&apos;s auto-response. We did not establish whether the object was hibernating, and we have not measured what hibernation saves.

## On the page

As of 15 October 2026, we defer the line on the home page with `server:defer` as an [Astro server island](https://docs.astro.build/en/guides/server-islands/) (see [static pages, live data](/blog/static-pages-live-data-astro-server-islands-cloudflare-workers-en/)). In the island, we read the newest public event through the Durable Object binding, falling back to the HTTP snapshot. We show a masked side as its region name and a kept country in the reader&apos;s language with `Intl.DisplayNames`. When there is no event, we render a fixed fallback sentence, never an invented one. Where that sentence comes from changed on 15 October 2026. In the version we examined for this note, the page also prerendered it in the island&apos;s `fallback` slot; a later revision that day removed the slot and moved the flag check into the island, which renders the sentence only when the line is switched on and has no event.

## What guards it

We guard the storage shape and the public shape separately. In the storage test, we assert the exact key set and require `from` and `to` to be objects. In the projection test, we assert that `id` is absent while `from` and `minutes` remain. On the sending side, we check the rounding, the minute cut, the opaque id, the unplaceable country and the skipped test people. We also test selection of the newest public event, supported response shapes and handling of malformed rows. Our record of a run on 15 October 2026 shows 56 passing tests in four website test files; the selection tests are not counted in that run.

## When this is the wrong pattern

**One object for everything.** Cloudflare&apos;s guidance is to [model Durable Objects around the &quot;atom&quot; of coordination](https://developers.cloudflare.com/durable-objects/best-practices/rules-of-durable-objects/), and its [December 2025 changelog](https://developers.cloudflare.com/changelog/post/2025-12-15-rules-of-durable-objects/) warns against &quot;a global singleton that becomes a bottleneck&quot;. Our atom is the whole home page, with one event per completed session: the public statistics snapshot timestamped 11:14 UTC on 15 October 2026 showed 3 verified completed sessions whose booked end fell on that UTC date. Our judgement, not a measurement, is that one object fits this volume. We broadcast each event to the connected clients, so a larger deployment should load-test that fan-out before relying on one object.

**The threshold is per country.** We mask countries with few active participants to limit geographical detail; assess the whole feed against your own population&apos;s disclosure model.

**Coarse is not anonymous.** A minute and a five-minute duration are still facts about a meeting. If you cannot defend publishing the time of day at all, publish hourly counts instead of events. For a new population, choose the geographical granularity and the suppression rule together, then test the result against your own disclosure model.

**Delivery is bounded and may repeat.** Design and test how long you remember ids separately from how long you keep events, and do not promise exactly-once display.

**Counts from elsewhere are the latest you hold.** We decide on cached counts, not on the current situation, and fetching them puts a dependency in the path that stores events; we accept that so we can define activity where we hold the verified session records. Specify and test a refresh and failure policy before you rely on a cache.

## Verify this

Copy `pairMeetsThreshold`, `place` and `maskLiveEvent` into your own TypeScript project, with minimal types, a `subregionOf` backed by `{ NO: &apos;Northern Europe&apos;, UA: &apos;Eastern Europe&apos; }` and a sample event from `NO` to `UA`. Mask it with a threshold of 20 and the counts `{ NO: 20, UA: 300 }`, then `{ NO: 19, UA: 300 }`, then `{}`, and once more with `from: &apos;ZZ&apos;`. You should get both countries, then both regions only, then both regions only, then no place for the unknown side. Figure 1 draws the first two results.

## Where our evidence stops

The code in this note is as it stood on 15 October 2026. On that day we checked the socket and the public read separately; we have not measured a real session travelling from settlement to the browser.</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Durable Objects</category><category>Cloudflare Workers</category><category>WebSockets</category><category>Privacy</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>Durable Objects for Rings, SQL for Outcomes</title><link>https://theeducationalequalityinstitute.org/blog/real-time-availability-with-durable-objects-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/real-time-availability-with-durable-objects-en/</guid><description>We had recorded the volunteer&apos;s answer, but still removed the learner from the lesson. Here is where live signals end and durable outcomes begin.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>We could take a learner out of a lesson after the volunteer had answered. We had recorded the answer in SQL, but asked our Durable Object for the outcome after we had dropped the finished ring. With the socket connected, we did not poll either. We now read the booking first and poll while the learner waits, whether or not the socket is connected. We still use one hibernating Durable Object per environment to hold WebSockets, send rings and start timeouts. We keep settled outcomes in SQL.

The rule behind it took us two failures to learn: we can keep a socket connected after forgetting a finished ring, so we use the Durable Object for signals and pending timeouts and read settled outcomes from SQL. This extends the live-plane section of [how TEEI runs its platforms on Cloudflare Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/).

**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.

## 8 September 2026: the answer was already in the booking

A learner on TEEI Language sees that a volunteer is available now and taps their name. We ring the volunteer&apos;s connected screen, and the volunteer answers, declines or lets it ring out. The learner waits in her room until she hears which.

Until 8 September 2026, the object was the only place we asked about the learner&apos;s ring from her room. We replay a ring from the object only while it is pending, drop a finished ring after a while to keep storage small, and answer &quot;unknown&quot; for a ring we no longer hold. We had also gated the learner&apos;s polling fallback behind `liveSocketConnected()`, so we did not poll at all for a learner with a working socket. After a learner reloaded, we could no longer show her that the volunteer had answered. We kept waiting for the answer, then removed her from the lesson.

We measured it in production on 8 September 2026. Of 102 times a learner or volunteer attended an &quot;Available now&quot; session, 51 ended early. Of 2,322 attendances in scheduled sessions measured the same way, 14 did. We have not repeated that measurement since the fix, so we cannot yet give an after figure. We had delivered the ring itself; what failed was our reading of its outcome after the object had dropped it.

![Two sequence diagrams. Before 8 September 2026, when a learner’s page reloaded, we asked the Durable Object about her ring, got unknown because we had already dropped the finished ring from the object, and took her out of a lesson that had been answered. After the fix, we read the booking first, get answered, and return at once; we ask the object only when neither the booking nor the video room shows a final outcome.](/images/blog/real-time-availability-with-durable-objects/ring-before-after.svg#wide,phone,fit &quot;Since 8 September 2026 we read the booking first and ask the Durable Object only when neither the booking nor the video room shows a final outcome.&quot;)

Source: TEEI, drawn from our platform code on 16 October 2026; the counts are from our production measurement on 8 September 2026.

Since 16 July 2026 we had recorded in the booking&apos;s own columns whether a ring was answered, and whether it was settled as declined or timed out. The volunteer&apos;s answer was written there before they could join the video meeting. We already had the answer in the booking. We had asked the wrong place.

The fix has two halves. In the route the learner&apos;s room polls, we now read the booking&apos;s columns first and return a terminal outcome from them at once, never overridden by the object. For a booking still pending, we next check whether the other person is already in the video room, which counts as answered. We ask the object only when neither has a terminal outcome, and for a pending booking we answer &quot;pending&quot; instead of &quot;unknown&quot;.

As of 15 October 2026, we request the learner&apos;s ring status in the room once when it opens, because an event that fired while the page was loading is gone, and then every 10 seconds while she waits, whether or not her socket is connected. We make that background poll with a fetch we catch locally rather than with `fetcher.load`, because a failed fetcher request can raise the room&apos;s route error boundary, and a failed poll should mean &quot;keep waiting&quot;, not an error screen. We anchor her local deadline to the original claim, so a reload cannot start a fresh waiting period. At the deadline we ask the server once more; if it still has no terminal answer, we retire the waiting banner and leave the call alone. We never infer a cancellation from silence.

We initialise local ring state from the loader, but revalidation changes loader data without rerunning the `useState` initialiser, so we copy a loader-delivered decline or timeout into local state explicitly. We added ten regression tests with the fix; one of them says the route &quot;answers from the columns when the live plane has forgotten the ring&quot;.

## Two planes

TEEI Language and TEEI Mentorship are one React Router codebase deployed as [two Workers](/blog/one-codebase-two-programmes-react-router-8-cloudflare-workers-en/), and each Worker has its own `LivePresence` Durable Object namespace. We address one object per environment through a fixed name.

The durable plane is Turso, our libSQL database. We keep there the windows in which volunteers are available, the bookings and, in the booking&apos;s columns, the outcome of every ring. The real-time plane is the Durable Object, and we keep two kinds of state in it. The open WebSockets, their attachments and the presence we derive from them are connection state: a deploy drops them, and we rebuild them from reconnecting browsers. In the object&apos;s own durable storage we keep two small values, the open rings with their deadlines and a set of rescue watches, and we set one alarm.

![Diagram of two planes. We write claims and answers to the database first, and for a decline we attempt the guarded cancellation there; then we signal a single Durable Object, which holds the open WebSockets, open rings, rescue watches and one alarm. We use the object to tell browsers that something changed, and we reload availability and settled booking outcomes from the database. A dashed arrow shows the alarm starting the ring timeout, which we settle in the database.](/images/blog/real-time-availability-with-durable-objects/two-planes.svg#wide,phone,fit &quot;We commit claims and answers before signalling connected browsers. For a decline, we attempt the guarded cancellation before sending the UI signal. We load availability and settled booking outcomes from the database. Polling interval checked on 15 October 2026.&quot;)

Source: TEEI, drawn from our platform code on 16 October 2026.

We send change signals through the object and keep content out of them. `live_changed` tells a learner&apos;s page that the list of available volunteers may have changed; it carries no payload and no roster, and the page reloads only what it is authorised to read. When someone receives a message, we send the recipient&apos;s sockets the conversation id, the kind of activity and the sender&apos;s role; for a new notification, at most its type code. We never send message or notification content over the socket: we fetch it through the page&apos;s own loader, on the same authorised route we would use without a socket. A ring carries more: the booking it belongs to, who is calling and when the ring expires.

As of 15 October 2026, this is the pattern on the learner&apos;s home page:

*Verbatim excerpt.*

```tsx
  useLiveSocket(Boolean(liveRailEligible), (message) =&amp;gt; {
    if (message.type === &apos;live_changed&apos;) refreshLiveRail(&apos;socket&apos;);
  });
  useEffect(() =&amp;gt; {
    if (!liveRailEligible) return;
    const timer = setInterval(() =&amp;gt; {
      if (!liveSocketConnected()) refreshLiveRail(&apos;poll&apos;);
    }, 60_000);
    return () =&amp;gt; clearInterval(timer);
  }, [liveRailEligible, refreshLiveRail]);
```

Whether we refresh after a socket signal or through the fallback poll, we read the list of available volunteers from the database through the loader, which is why we run the Workers [near the database, not the user](/blog/why-our-workers-run-near-the-database-not-the-user-en/). A signal lost while the socket stays connected can leave the list stale, and a learner may then tap a volunteer who has just gone. That costs her a &quot;no longer available&quot; message, never a double booking, because we check the claim against the offer in the same transaction that creates the booking.

Presence follows the same rule. We announce a volunteer as online when we accept their first socket and offline when we close their last; another tab changes nothing. We have never broadcast learners&apos; own presence, and the counts we send for a group session&apos;s door or a Practice Now queue are numbers without names, the same principle behind [a public live activity feed without participant identities](/blog/public-live-activity-feed-without-participant-identities-en/).

## Write first, then signal

A learner&apos;s claim starts in the database. When a learner claims an available volunteer, we send Turso one batch that runs as a single transaction: we create the booking only if the volunteer&apos;s offer is still open at the generation the learner saw, close the offer and queue the volunteer&apos;s notices. A conditional insert can succeed while inserting nothing, and the consume update can rightly change no row when a trigger has already consumed the offer, so we end the batch with statements that raise an error unless the booking, the consumed offer and the queued notices agree, and that error aborts the whole batch. When two learners tap the same volunteer, one generation of the offer wins and we tell the other learner it is gone.

A remote transaction can commit while its HTTP response is lost. We do not retry the write then. We look up the booking that won that generation of the offer and return it, and on a retried request we resend only the `claimed` signal to the object. The notices were committed with the booking and are never queued twice. While the ring remains in the object&apos;s storage, we replay a repeated `claimed` signal only while it is pending and unexpired, without changing its stored deadline or terminal outcome.

The booking commits before we attempt a bounded notification to the object. A failed notification does not roll back the booking: as checked on 15 October 2026, the request waits up to approximately two seconds for that attempt, and if it fails, we log the failure and keep the booking.

## One object, hibernating, and how we rebuild it

We keep idle connections open without keeping the object active through three parts of the [WebSocket Hibernation API](https://developers.cloudflare.com/durable-objects/best-practices/websockets/). We accept each socket with `this.ctx.acceptWebSocket(socket, tags)`, so that finding every socket of a role, or one volunteer&apos;s sockets, is a tag lookup with `getWebSockets(tag)`, never a walk through memory. We write a small attachment per socket with `serializeAttachment` and read it back with `deserializeAttachment`. And we register a ping and pong pair with `setWebSocketAutoResponse`, so we let the runtime answer the browser&apos;s heartbeat without waking the object.

When an event wakes a hibernated object, we start again in the constructor with empty memory, and nothing needs to be in it: we take counts from `getWebSockets` and the attachments, read the open rings from storage, and derive presence from which sockets are open.

Over 30 UTC days, 15 September to 14 October 2026 inclusive (from 00:00 UTC on 15 September up to, but not including, 00:00 UTC on 15 October), the TEEI Language object was active for 38.1 of 720 hours, 5.29 % of the time, while holding up to 152 WebSockets at once. We derived the unit of `activeTime` from billed duration. And we have not run the same traffic without hibernation, so we cannot say how much of the 5.29 % is hibernation and how much is an object with little to do.

![Chart of the TEEI Language Durable Object over 30 days from 15 September to 14 October 2026. Bars show the most WebSockets open at once each day, between 45 and 152, with the peak of 152 on 13 October. A line shows hours active each day, between 0.55 and 1.96. Over the whole period the object was active 38.1 of 720 hours.](/images/blog/real-time-availability-with-durable-objects/thirty-days.svg#phone,fit &quot;We kept up to 152 sockets open while recording less than two active hours per day. Measured on 15 October 2026 from Cloudflare’s GraphQL Analytics API; we have no run without hibernation to compare against.&quot;)

Source: TEEI, from Cloudflare’s GraphQL Analytics API, Durable Object periodic metrics by day, read on 15 October 2026.

Tags are fixed when we accept a socket, but some of what we need to know changes while the socket lives. Which group session&apos;s door a learner is standing at is a property of the open page, not of the account. We receive that from the browser and keep it in the attachment, which we can rewrite. We rebuild the attachment field by field rather than spreading the old one, because when someone leaves a door without entering another, omitting the old field is the update. For the room&apos;s waiting message, we count distinct other accounts: until 1 October 2026 a learner with two tabs open saw her own second tab announced as someone waiting for her. And we keep watching apart from standing, so a page that only wants a count hears it without being counted.

Hibernation and eviction keep healthy sockets and their attachments. A deploy does not: &quot;Code updates disconnect all WebSockets.&quot; We rebuild socket state from each browser&apos;s page. When the browser&apos;s socket opens again, we claim its door again from the page, then its Practice Now queue or watch, then the doors it watches, because &quot;a reconnect starts from a socket the DO has never heard of&quot;. We regain presence as volunteers&apos; sockets reconnect. We rebuild socket-derived state from reconnecting clients and reload displayed data from Turso. Pending rings are different: we retain them in durable storage because the alarm needs them.

The one ring the browser cannot re-announce is one it has not heard about yet, such as a volunteer opening a fresh tab from a push notification. Before 11 August 2026 we read the open rings from storage before answering the volunteer&apos;s upgrade request, so a storage read that timed out would have turned a valid handshake into HTTP 500. We have no record of that causing a production incident. We now answer 101 first, deliver the pending ring afterwards inside `ctx.waitUntil`, and, if that read fails, close the socket with code 1013, &quot;try again later&quot;, so we reconnect from the page and poll instead of trusting a socket we know is missing a ring.

## The alarm and the input gate

In the code checked on 15 October 2026, we give a ring four states in the object, pending, answered, declined and timeout, and a deadline of two and a half minutes, the code&apos;s &quot;would a human still be waiting&quot; threshold. We commit the learner&apos;s claim in Turso and then send the object `claimed`. We write the ring into storage, point the alarm just past its expiry and ring the volunteer. If the volunteer answers, we record the answer in the booking before they join the video meeting, and only then signal the object. If they decline, we first attempt the guarded cancellation in the database, then send the UI signal. If the alarm fires first, we ask the database to settle the ring as unanswered.

We never let the alarm write &quot;timeout&quot; on its own authority. We use the same settlement function for a decline and an alarm timeout: a guarded `UPDATE` that changes the booking only if nothing else has settled it, and, if nothing changed or the response was lost, a read of the booking. In the function&apos;s own words, &quot;The read below is the authority; never issue a compensating write.&quot; If the answer reached Turso a moment before the alarm, our update changes nothing, we read &quot;answered&quot;, and we tell both sides the call was answered. When we settle a timeout we also queue the volunteer&apos;s missed-call notice, an in-app notification mirrored to Web Push, in the same transaction. We attempt delivery from the alarm and retry undelivered notices in a scheduled job. Email follows the same shape, a row committed with the change and delivered afterwards, in [reliable email with Cloudflare Queues and a transactional outbox](/blog/reliable-email-cloudflare-queues-transactional-outbox-en/).

In the object, terminal outcomes are first-writer-wins: whichever terminal signal reaches it first. We settle the race between an answer and a cancellation with guarded database writes, so the object&apos;s local outcome is not guaranteed to match the database&apos;s. That is one more reason we read outcomes from the booking.

### Committing decisions, not snapshots

We could overwrite newer ring state while waiting for the database. With a Durable Object&apos;s [input gate](https://blog.cloudflare.com/durable-objects-easy-fast-correct-choose-three/), we process no new events while awaiting the object&apos;s storage; while awaiting a database `fetch`, we can process other events. Until 12 September 2026, the alarm read the map of rings, awaited the database and the missed-call notice for each expired ring, then wrote the whole map back. A ring claimed during one of those awaits could be overwritten by the alarm&apos;s older map, losing its timeout with it, and in the same window a recorded answer could be overwritten by a stale timeout decision. We did not find this in production. It came out of a review of our test model on 11 September 2026, was confirmed in the code and fixed the next day; we have not measured whether it affected a real booking.

We now treat `(claimedAt, expiresAt, outcome)` as the ring&apos;s version. We re-read storage before committing and apply a decision only where those three values still match; a changed display name is not a new version:

*Adapted for publication.* Comments shortened and the method dedented for display; the logic is unchanged.

```ts
// Is `fresh` still the same GENERATION of this ring as the copy a decision was taken from?
export function ringGenerationMatches(
  fresh: RingState | undefined | null,
  evaluated: RingState,
): boolean {
  if (!fresh) return false;
  return fresh.claimedAt === evaluated.claimedAt
    &amp;amp;&amp;amp; fresh.expiresAt === evaluated.expiresAt
    &amp;amp;&amp;amp; fresh.outcome === evaluated.outcome;
}

// Only storage reads and writes are awaited here, which the input gate holds exclusively.
private async commitRingDecisions(
  decisions: Map,
): Promise {
  if (decisions.size === 0) return;
  const fresh = await this.readRings();
  let changed = false;
  for (const [bookingId, { evaluated, next }] of decisions) {
    if (!ringGenerationMatches(fresh[bookingId], evaluated)) continue;
    if (next === null) delete fresh[bookingId];
    else fresh[bookingId] = next;
    changed = true;
  }
  if (changed) await this.writeRings(fresh);
}
```

Re-reading protects the unrelated entries in the map; comparing the evaluated generation protects the entry we were deciding about. In the 12 September 2026 controls we tested them separately. Restoring the stale whole-map write lost both an unrelated claim and an answer to the ring being settled. Keeping the fresh read but removing the generation comparison preserved the unrelated claim and still overwrote the answer. The guarded SQL write protects the booking, and the generation comparison protects the object&apos;s stored copy.

We did not wrap the alarm in `blockConcurrencyWhile`. On one object that holds every socket in the environment, that would have held up every socket message for as long as the database took to answer. The guarding test is named for the behaviour, &quot;does not overwrite a ring answered while the alarm was awaiting the database&quot;, and it only fails against the old code because our revised fake returns detached copies, so the test can expose stale writes. Those tests do not establish object-identity behaviour in the production runtime. Our earlier fake returned stored objects by reference, so we mistook a memory mutation for a storage write and passed the test with the bug still present.

### One alarm, two jobs

A Durable Object has one alarm, and we share ours between ring timeouts and rescue watches, with which we wake the object at the moments a scheduled session might need help, rather than waiting for the next scheduled run. We move the alarm earlier, never later, unless the stored one is already in the past. If we cannot settle an expired ring because the database is unavailable, we retry after a fixed interval. Retrying immediately would turn the outage into an alarm loop.

The shared alarm also shared a failure. We had measured a rescue watch&apos;s age from a boundary we moved forward on every check, so watches never retired and their history kept growing. On 6 August 2026, the object&apos;s production writes failed with `SQLITE_TOOBIG`, the alarm&apos;s among them. We corrected the growth defect: we now measure retirement from a fixed anchor and cap each retained history on read. That does not by itself bound the combined map&apos;s size. The recorded errors had already stopped before that fix was deployed, so we do not attribute the incident&apos;s recovery to it. The regression test drains until the watch disappears; one drain would pass with the old rule. We also skip malformed entries, because throwing here would take ring timeouts down too.

During database maintenance we settle nothing, re-arm the alarm a minute later and return normally, as checked on 15 October 2026. Cloudflare&apos;s [`alarm()` documentation](https://developers.cloudflare.com/durable-objects/api/base/), checked that day, guarantees at-least-once execution with up to six retries; an alarm that keeps throwing runs out of them and loses its timeout.

## What the object keeps durable

With outcomes in the booking, we can lose socket-derived state without changing a settled booking, and lost rescue watches only delay the rescue work until the scheduled job runs. Pending rings are the state we keep durable: we persist them with their deadlines, preserve the alarm&apos;s work and compare versions before applying its decisions.

If your timeouts carry money or a promise to a person, persist their deadlines and test that they settle with no socket connected. A scheduled job that settles any pending claim older than its deadline keeps the object the fast path rather than the only path.

## Reading the object&apos;s analytics

Cloudflare&apos;s [metrics and analytics](https://developers.cloudflare.com/durable-objects/observability/metrics-and-analytics/) for Durable Objects need care with WebSockets. A WebSocket connection appears in &lt;code&gt;durableObjects&lt;wbr&gt;&lt;/wbr&gt;Invocations&lt;wbr&gt;&lt;/wbr&gt;AdaptiveGroups&lt;/code&gt; once, when it closes, and two disconnect statuses, `clientDisconnected` and `responseStreamDisconnected`, dominate what is marked as an error. In our sampled analytics for the same 30 UTC days, 61,243 of the 62,245 TEEI Language invocations marked as errors were `http` invocations with one of those two statuses. The average wall time of a `clientDisconnected` call was about 23 minutes, consistent with socket lifetimes rather than short requests. An alert on this object&apos;s raw error rate would mostly be measuring sockets that closed.

Alarm invocations are not a count of rings either: rings, rescue boundaries, retries and deferred runs all set the alarm. We have not measured how many rings were answered, declined or timed out, nor the time from a claim to the volunteer&apos;s screen ringing.

## Where this design stops

**Authority.** If you build a collaborative document, a game or a chat whose message history lives in the object, make the object the authority, use its storage and back it up. Our design works because every outcome that matters already had a home in a relational database the rest of the platform reads.

**Size.** The [documented ceiling](https://developers.cloudflare.com/durable-objects/api/state/) is 32,768 WebSockets per object, well above the 152 we saw at most in the 30 UTC days we measured. We must also account for CPU and memory, which may limit the practical number first. Before choosing one object for a larger audience, measure CPU, memory and broadcast costs at your expected peak load. Consider objects scoped to rooms or programmes; splitting a global object later changes every place that counts.

**Recovery.** We rebuild state by resending the browser&apos;s page state after every reconnect. For clients without that behaviour, we need to persist more state and design recovery for it.

## Verify this

- **Hibernation in your own account.** Deploy Cloudflare&apos;s [WebSocket hibernation server example](https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server/), add `setWebSocketAutoResponse` in the constructor, keep a few tabs connected and idle for an hour, then compare `sum { activeTime }` with `max { activeWebsocketConnections }` in `durableObjectsPeriodicGroups`. Store a value with `serializeAttachment`, send a message later, confirm the constructor ran again and read the value back.
- **The input-gate window.** In your own object, have `alarm()` read a stored map and pause at a controlled `fetch`. Add a key through a request and wait for that request to finish before releasing the fetch. A stale whole-map write loses the key; a fresh read preserves it. Repeat by changing the evaluated entry: preserving it also requires comparing its generation. Make test storage return detached values, for example with `structuredClone`.

On 15 October 2026 we ran the ten test files that cover the object, the protocol, the notifier, the ring-status route, the presence route and the Worker&apos;s socket handling: 131 tests, all passing. They run in Vitest with fakes, not against `workerd`, which is why the fake&apos;s storage semantics matter so much. The analytics, the tests and the 8 September measurement come from our own account, repository and production logs, and we keep the queries and raw output with the source of this note.</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Durable Objects</category><category>WebSockets</category><category>Cloudflare Workers</category><category>Hibernation</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>Why We Freeze Email Payloads on Cloudflare Queues</title><link>https://theeducationalequalityinstitute.org/blog/reliable-email-cloudflare-queues-transactional-outbox-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/reliable-email-cloudflare-queues-transactional-outbox-en/</guid><description>We could reuse an idempotency key and still change the payload after a deploy. We now store the first attempt’s message fields so a retry can reuse them.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>We gave every email an idempotency key. Before 29 September 2026, if we lost the provider&apos;s response and then changed the email layout, we would retry with different bytes under the same key. Resend would answer with a 409, and we would mark the email as failed, even though the first attempt might have reached the recipient. We now store the first attempt&apos;s message fields before sending and reuse them on retry when the payload store is available. The key protects a retry only when the request carries the same content and arrives within the provider&apos;s idempotency window.

Around that, we use a transactional outbox on [Cloudflare Queues](https://developers.cloudflare.com/queues/): we record the intent in the database, publish the outbox reference with its message kind, version and optional tracing metadata, and use an atomic claim to admit a delivery while its claim lasts.

We built it for [Evenhall](/partners/evenhall/), where a partner company&apos;s managers see what their employees did through the programmes of TEEI, a nonprofit, and turn it into a report they can file. TEEI&apos;s free programmes connect people from Ukraine, and women from Latin America and the Caribbean, with volunteers who help them practise a language and build a career. Evenhall is a React Router 8 application on a Cloudflare Worker, with a [Turso](https://turso.tech/) (libSQL) database and Resend for email, and we send email when a manager invites employees, a company applies or someone asks for a demo. Each of those emails crosses three systems that cannot share one transaction: our database, the queue and the email provider. The [overview of how TEEI runs on Cloudflare Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/) has a short section on its queues.

**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.

## One invitation, from a manager&apos;s click to the hours in a report

The invitation is the email this note keeps returning to, and the step-by-step table further down follows one through a deploy. It is also where an employee&apos;s volunteering with TEEI starts.

A manager at a partner company invites colleagues from Evenhall by email. The email says what they will do (take part in the TEEI programmes the company supports) and how their time is counted: hours are measured from session attendance, so there is nothing to log. After accepting, each colleague creates a volunteer account in TEEI Language or TEEI Mentorship. In TEEI Language, volunteers set the times they are free and learners book sessions inside them. Hours from the sessions they hold are what the company&apos;s report in Evenhall counts. [How corporate volunteering works at TEEI](/blog/how-corporate-volunteering-works-at-teei-en/) follows that journey from the employee&apos;s side.

![Four crops of phone screens, each with a numbered label. One, Evenhall&apos;s demo with sample data: a manager&apos;s Invite by email card with a Send invitations button; Evenhall on a Cloudflare Worker records the invitation, then its outbox row, and puts the outbox reference and metadata on the queue. Two, the invitation email with the demo&apos;s sample names: Jessica Martinez at Horizon Technologies invited you to volunteer through TEEI&apos;s programmes; Cloudflare Queues, then Resend, under the row&apos;s idempotency key. Three, the public tour of TEEI Language: a volunteer&apos;s page that asks When are you free?; after accepting, the employee creates a volunteer account in TEEI Language or TEEI Mentorship. Four, Evenhall&apos;s demo report for September 2026, with 789 sessions, 1,040.25 volunteer hours and 514 people.](/images/blog/reliable-email-cloudflare-queues-transactional-outbox/one-invitation.svg#wide,phone,fit &quot;One invitation, from the manager&apos;s click to the hours in the company&apos;s report, and where the queue and Resend come in.&quot;)

Source: TEEI, captured from Evenhall&apos;s public demo and the public tour of TEEI Language on 17 October 2026, and the top of the invitation email rendered the same day from Evenhall&apos;s template with the demo&apos;s sample names. All data shown is sample data.

That is why the failures below matter to us. If the invitation arrives twice, the first thing a colleague sees from the programme is a duplicate. If it reached the inbox but is recorded as failed, our record is wrong about whether someone was asked to start.

What the invitation starts looks like the work of Heidi B., a volunteer language partner in TEEI Language who comes to every session with a topic ready.

![Heidi B., a volunteer language partner in TEEI Language](https://r2.theeducationalequalityinstitute.org/videos/cockpit/square/heidi-b-1.mp4?v=2026-09-22b#square &quot;Heidi B., a volunteer language partner in TEEI Language, on preparing each session and meeting her learners regularly.&quot;)

Source: TEEI, a film recorded with Heidi B.&apos;s consent; this square cut rendered on 22 September 2026.

Evenhall is not TEEI&apos;s only sender on Resend. Of the emails TEEI Language and TEEI Mentorship logged in the seven days to 01:30 UTC on 17 October 2026, 4,077 were recorded as sent through Resend, among them session reminders and booking confirmations, and 4,054 had a delivery receipt from Resend. Those platforms have a send path of their own, which this note does not describe.

## Three ways to send an email twice, or not at all

**The queue delivers at least once.** Cloudflare&apos;s [delivery guarantees](https://developers.cloudflare.com/queues/reference/delivery-guarantees/) say that a message, &quot;in rare occasions, may be delivered more than once&quot;. If we send once for every delivery, a duplicate delivery can produce a duplicate email.

**The database and the queue cannot share a transaction.** Publishing first can leave a message without its database record; writing first can leave a committed record whose message was never published. That is the problem the [transactional outbox pattern](https://microservices.io/patterns/data/transactional-outbox.html) exists to solve.

**The provider&apos;s answer can be lost.** Resend may accept an email and the response may never reach us. On retry, we need the provider to reuse an accepted request or send once if the first request never landed. [Resend&apos;s idempotency keys](https://resend.com/docs/dashboard/emails/idempotency-keys) are built for that, on two conditions: the retry must carry the same payload, and it must arrive within the provider&apos;s window, 24 hours in the documentation we read on 15 October 2026.

These are separate failure boundaries. An outbox transaction records an obligation; a relay makes that obligation eligible to run; a claim controls concurrent attempts; provider idempotency reconciles repeated requests; a receipt tells us about delivery after acceptance. None of those steps substitutes for another.

| Boundary | What a successful step establishes | What still needs evidence |
|---|---|---|
| Domain transaction | The change and its email intent exist together | A relay will publish the intent after a crash |
| Queue publication | A message was accepted by the queue | The consumer will send, rather than merely acknowledge it |
| Conditional claim | This attempt acquired the row for the claim&apos;s lifetime | Another attempt cannot outlive it, or the provider has accepted nothing yet |
| Provider acceptance | The provider accepted a request and returned a message ID | The recipient received the email |
| Delivery receipt | A matched provider event was recorded | Every earlier ambiguous attempt has been reconciled |

The transaction below is a demo-request example. Our email families do not all have the same atomic write and recovery coverage, so this is not a claim of eventual delivery or exactly-once effects across every flow. We describe the guarantees at the boundary that implements them.

![Sequence diagram: we write the change and an outbox row in one database batch, then publish the outbox reference and metadata to a Cloudflare Queue. We claim the row with one UPDATE, freeze or replay the message fields and send them to Resend with an idempotency key, then record the provider’s message ID and acknowledge the message.](/images/blog/reliable-email-cloudflare-queues-transactional-outbox/one-email.svg#wide,phone,fit &quot;One demo-request email in Evenhall, from the database transaction to the provider. We publish the outbox reference and metadata and keep its delivery status in the database.&quot;)

Source: TEEI, Evenhall’s code as of 15 October 2026.

## The row is the truth; the message points to it

We keep contact details in the database and publish this queue envelope:

*Verbatim excerpt.*

```ts
/**
 * Deliberately contains no contact details. Queue payloads are visible in
 * Cloudflare operations tooling, so the consumer resolves all PII from Turso.
 */
export type CsrEmailQueueMessage = Readonly&amp;lt;{
  kind: &quot;csr-email-outbox&quot;;
  telemetry?: { sentryTrace?: string; requestId?: string };
  outboxId: string;
  version: 1;
}&amp;gt;;
```

The row holds the delivery record: the recipient, the template and its version, the idempotency key, the provider&apos;s message ID once there is one, the attempt count, the last error and a status. Three database guarantees sit under it. A unique idempotency key prevents a second row under the same key, and the conditional UPDATE further down prevents two concurrent claims. A partial unique index prevents one provider message ID from identifying two rows.

Keeping the message this small keeps personal data out of the queue, so nothing in Cloudflare&apos;s queue tooling or metrics shows who was emailed. It also leaves the row as the one record of what has been sent: when a duplicate or late message arrives after the row has settled, we read the row and do nothing. Before sending, we read the outbox row from the database. Our Workers are built around round trips to one database, and why they run near the database rather than near the user is a note of its own.

## Writing the row in the same transaction

The outbox only helps if we write the row in the same transaction as the change that caused the email. For a demo request, we record the lead, store the submitted form and insert the outbox rows in one write batch, and we publish only after the batch commits:

*Adapted for publication.*

```ts
await db.batch(
  [
    upsertLead,          // INSERT ... ON CONFLICT(email) DO UPDATE
    insertSubmission,    // INSERT INTO formSubmissions ...
    ...buildEmailOutboxInsertStatements({ entries: outboxEntries, formId, now }),
  ],
  &quot;write&quot;,
);

// Only after the commit: publish one envelope per outbox row.
const enqueueResult = await enqueueEmailOutbox(env, outboxEntries);
if (!enqueueResult.queued) {
  logger.warn(&quot;Demo contact email outbox remains pending&quot;, {
    errorCode: enqueueResult.errorCode,
    formId,
  });
}
```

We run the statements in one transaction using libSQL&apos;s `batch` in `&quot;write&quot;` mode, so the lead, the form and the email intent exist together or not at all. We insert into the outbox with `ON CONFLICT(idempotency_key) DO NOTHING`, so replaying it cannot create a second row. We still await the transaction and publication attempt in the HTTP request; we move only the provider call off it.

We handle company applications differently. When we decline an application or ask for more information, we record the decision and insert an email intent in one transaction, with `INSERT OR IGNORE` so that a repeat does nothing. We retry acquiring a busy write transaction a few times, before running any application statements; after that, an ambiguous commit error goes back to the caller. The helper&apos;s comment says why: &quot;Never replay an ambiguous commit/network failure as a fresh transaction.&quot; In the code as of 15 October 2026, a relay that runs on a schedule then writes the outbox row, its stored message input and the intent&apos;s queued marker in one transaction, and only then publishes; in the same pass, it publishes rows that were committed and never published.

Publishing is the step that is allowed to fail. In `enqueueEmailOutbox` we call `sendBatch` and then set the rows to `enqueued`, three tries with a short doubling pause. We update that state only while a row has no provider message ID and is still `pending`, `enqueued` or `retry`, so a late update cannot overwrite a claim or a settled status.

If all three tries fail, or the queue binding is missing, we record the eligible rows as `pending` with the reason and return `{ queued: false }`; only a failure to write that state reaches the caller. We may have published the message before the state update failed, so we cannot infer that nothing reached the queue. We may therefore publish a row twice, on a second try or from a relay; we must handle duplicate deliveries in the consumer anyway.

## The consumer: one decision per message

In our configuration, Cloudflare can deliver several email messages in one invocation, and we handle them one at a time. Two rules from [Cloudflare&apos;s batching documentation](https://developers.cloudflare.com/queues/configuration/batching-retries/) shape the loop. If one message fails and nothing in the batch has been acknowledged, &quot;the entire batch is retried&quot;; when a message receives both `retry()` and `ack()`, &quot;The first method call wins in all cases.&quot; So we decide each message on its own, exactly once, and never call `retryAll()`:

*Adapted for publication.*

```ts
export async function consumeEmailQueue(batch: MessageBatch, env: Env) {
  const isDeadLetterQueue = batch.queue === env.EMAIL_DLQ_NAME;

  for (const message of batch.messages) {
    if (!isQueueMessage(message.body)) {
      // Invalid envelope: acknowledge without reading or updating an outbox row.
      message.ack();
      continue;
    }
    try {
      if (!isClaimedOutboxId(message.body.outboxId)) {
        // The envelope carries an ID, but a matching row may not exist.
        // Mark an eligible row failed first; if this write throws, the message retries.
        await markUnclaimedOutboxRow(env, message.body.outboxId);
        message.ack();
        continue;
      }
      if (isDeadLetterQueue) {
        await markDeadLetter(env, message.body);
        message.ack();
        continue;
      }
      const result = await processOutboxJob(env, message.body, message.attempts);
      if (result.kind === &quot;retry&quot;) message.retry({ delaySeconds: result.delaySeconds });
      else message.ack();
    } catch (error) {
      captureError(error, { attempt: message.attempts });
      message.retry({ delaySeconds: retryDelaySeconds(message.attempts) });
    }
  }
}
```

We ask two questions at the top, and they look like one: is this message ours at all, and which kind of email does its ID belong to? We acknowledge an invalid envelope without touching the database. For a valid envelope whose ID no family recognises, we mark an eligible row `failed` before we acknowledge. That order comes from the incident further down.

We consume the [dead-letter queue](https://developers.cloudflare.com/queues/configuration/dead-letter-queues/) in the same Worker. We pass exhausted deliveries to the relevant email-family handler, then acknowledge after it returns. One Worker for both queues keeps routing simple, and means one broken deploy affects both. The main queue retries a message a bounded number of times before it moves to the dead-letter queue, and we set each delay ourselves with `retry({ delaySeconds })`, as in the loop above; the queues have no retry delay of their own configured.

`message.attempts` [starts at 1](https://developers.cloudflare.com/queues/configuration/javascript-apis/), which the delay in the loop relies on. On our exponential retry paths the delay doubles with each attempt and stops growing at a cap, so a message&apos;s retries can spread over a few hours and a temporary provider outage has time to clear. We have not tested this against a real outage. Other paths use shorter, fixed delays.

We also cap how many consumer invocations may run at once, while [Cloudflare recommends](https://developers.cloudflare.com/queues/configuration/consumer-concurrency/) leaving that setting unset so that the consumer can scale. The reason for our setting is not written down, and we have not measured whether it ever binds.

## How we claim a row with one UPDATE

Before sending anything, we claim the row. We use one conditional UPDATE to decide which delivery may send, here as it stood on 15 October 2026:

*Adapted for publication.*

```ts
async function claimOutbox(
  env: Record,
  outboxId: string,
): Promise {
  const db = getClient(env);
  const now = new Date();
  const claimExpiresAt = new Date(now.getTime() + CLAIM_WINDOW_MS).toISOString();
  const result = await db.execute({
    sql: `UPDATE csr_email_outbox
          SET status = &apos;processing&apos;,
              attempt_count = attempt_count + 1,
              claim_expires_at = ?,
              next_attempt_at = NULL,
              updated_at = ?
          WHERE id = ?
            AND provider_message_id IS NULL
            AND (
              status IN (&apos;pending&apos;, &apos;enqueued&apos;, &apos;retry&apos;)
              OR (
                status = &apos;processing&apos;
                AND (claim_expires_at IS NULL OR claim_expires_at &amp;lt;= ?)
              )
            )`,
    args: [claimExpiresAt, now.toISOString(), outboxId, now.toISOString()],
  });
  return result.rowsAffected === 1;
}
```

We use these conditions to prevent the failures above. Once we have stored the provider&apos;s message ID, the row cannot be claimed again, however many times its message arrives. Only a waiting row can be claimed, and `pending` is on the list, so a row whose publication was never recorded can still be sent. The expiry means a Worker that died in the middle of a send does not hold the row for ever.

When two deliveries compete for a claim, we let both run the conditional UPDATE; only one changes the row. We test eligibility and change the row in the same statement. A SELECT before it could show both deliveries a pending row, and it would reserve the row for neither.

The claim alone does not make an email go out once. We admit one delivery through the conditional UPDATE while its claim is valid. After the claim expires, another delivery may take over even if the first is still waiting for the provider. We use the idempotency key to reconcile attempts whose outcome we could not record, including a lost response or an earlier request still waiting when its claim expired.

## Freezing the payload before the first attempt

Every send carries the idempotency key stored on its outbox row. [Resend&apos;s documentation](https://resend.com/docs/dashboard/emails/idempotency-keys), read on 15 October 2026, says what that buys: for 24 hours, the same request with the same key gets &quot;the same response, without actually sending the email again&quot;. It also documents the condition, as a 409 with the code `invalid_idempotent_request`: &quot;this idempotency key has already been used on a request that had a different payload.&quot;

Before the fix, we sent a newly rendered message on every attempt, which is the same request only while the rendering never changes. The sequence that breaks it is a lost response followed by a deploy. The first attempt reaches Resend, Resend accepts it, the answer never arrives, and the row goes to `retry`. Before the retry, we deploy a new version of the shared email layout.

On retry, we render the new layout, present different bytes under a key Resend already holds and get a 409. Before the fix, we then wrote the row as `failed`, which says that nothing was sent, while the first attempt may well have reached the recipient. Our campaign emails could meet the same 409 without any deploy, because each render of a campaign email produces slightly different bytes.

The fix landed for report emails on the morning of 29 September 2026 and for invitations, demo requests, applications and campaigns two hours later. We documented the hazard in the fix&apos;s description without recording an observed case, so we do not know how often it happened.

![Two timelines. In both, the first attempt is accepted and its response is lost, then a new email layout is deployed. When we retry with newly rendered fields, the provider refuses the different bytes with a 409. When we retry an otherwise identical request with the first attempt’s stored fields, we receive the original message ID and the provider sends no second email.](/images/blog/reliable-email-cloudflare-queues-transactional-outbox/retry-timelines.svg#wide,phone,fit &quot;In a sequential retry within Resend’s 24-hour idempotency window, we receive the original message ID without another send when the request is otherwise identical to the first.&quot;)

Source: TEEI, Evenhall’s code and tests as of 15 October 2026; Resend’s documentation, read on 15 October 2026.

Here is the right-hand timeline for one invitation like the one at the top, step by step, as our model test runs it.

Table: One invitation across a deploy since 29 September 2026, as our model test of the invitation email runs it. Where a cell says asserted, the test checks it; the other states are read from the code.

| Step | What happens | The outbox row | Resend |
|---|---|---|---|
| 1 | **First attempt.** We claim the row, store fields A and send them with key K | `processing`, attempt 1 | Accepts fields A under key K and sends the email |
| 2 | **Lost response.** The answer never reaches us | `retry` (asserted), no message ID, fields A stored (asserted) | Still holds key K with fields A |
| 3 | **Deploy.** A new shared email layout goes live; rendering the invitation now gives fields B | Unchanged | Unchanged |
| 4 | **New attempt.** We claim the row again, render fields B, find fields A stored and send fields A with key K | `processing`, attempt 2 | Receives the same key with the same payload |
| 5 | **Same message.** We record the answer and delete the stored fields (asserted) | `accepted` with the original message ID (asserted) | Returns the original message ID and sends nothing; one email in total (asserted) |

Before the fix, step 4 sent fields B and step 5 was a 409, which we wrote as `failed`. The test checks the result on both sides of the provider call: the second request has the same fingerprint as the first, its HTML has no trace of the new layout, and the model has sent one email. That fingerprint covers the fields we hand to the provider wrapper (sender, recipient, reply-to address, subject, HTML, text and tags), before tag cleaning and the environment tag; it is not a fingerprint of the final HTTP request, and it leaves out the key and any attachment. It does not assert the key on its own; in the model, only the same key can return the original message ID. The same test runs for demo requests, applications and campaigns, and a separate one for report emails.

We freeze like this:

*Adapted for publication.*

```ts
/**
 * Call after the consumer&apos;s checks on the row, immediately before the
 * provider wrapper. Returns the payload of the FIRST attempt when one was
 * stored, and otherwise stores `rendered` and returns what was stored (which is
 * another attempt&apos;s payload if it froze first).
 */
export async function freezeOrReplayOutboxMessage(db, outboxId, rendered) {
  // The store holds one attachment. No email family sends more; one that did would keep rendering.
  if ((rendered.attachments?.length ?? 0) &amp;gt; 1 || !(await frozenPayloadStoreAvailable(db))) {
    return { freezeAvailable: false, message: rendered, replayed: false };
  }
  const frozen = await loadFrozenPayload(db, outboxId);
  if (frozen) {
    return { freezeAvailable: true, message: withFrozenPayload(rendered, frozen), replayed: true };
  }
  const stored = await freezePayload(db, outboxId, toFrozenPayload(rendered));
  return { freezeAvailable: true, message: withFrozenPayload(rendered, stored), replayed: false };
}
```

We freeze the message fields we hand to the provider wrapper: sender, reply-to address, subject, HTML, text, tags and an attachment. We keep the recipient, the idempotency key and the audience from the message we prepare for each email family; freezing does not replace them.

We freeze after the claim and row checks, immediately before the provider call. After `INSERT OR IGNORE`, we read back and validate the stored row. We use the fields stored first, even if they came from another attempt, and throw rather than send a partial payload.

The guarantee depends on the frozen-payload store being available, and before relying on replay we check that the store has every column the reader needs. Replaying stored fields does not extend the provider&apos;s 24 hours. Our model tests cover sequential retries across a layout change, and they show the later attempt reusing the first attempt&apos;s stored fields.

The claim lifetime and the provider window solve different problems. Claim expiry lets work recover after a consumer disappears; it is not evidence that the earlier provider request failed. The provider window lets an otherwise identical request return its earlier answer; it is not a permanent uniqueness constraint. Once that window has elapsed, reusing the same key cannot by itself justify another send. That is why an ambiguous result needs a durable record and a reconciliation decision, rather than a new key that merely makes the next request easier to accept.

We verify the replayed fields with a model; we do not verify the final HTTP body, where we still clean the tags and add the environment tag in the wrapper on every attempt. The stored payload contains personal content, so in the send path we delete it when the provider accepts the email or returns a final result.

## What the provider&apos;s answer means

We handle acceptance, an idempotency conflict and other rejections in separate branches. On acceptance, we record the provider&apos;s message ID on the row under the same `provider_message_id IS NULL` guard as the claim. A 409 with `invalid_idempotent_request` can still arrive, for example from a row first attempted before the freeze existed. We write it as `dead_letter` with `unknown_outcome`, because it proves that an earlier request with the key reached the provider and says nothing about whether that email went out.

That `dead_letter` is a row status in our database, not Cloudflare&apos;s dead-letter queue. A 409 with `concurrent_idempotent_requests` means another request with the key is still in progress, and we retry it. Everything else is either retryable (429, any 5xx, a transport failure, an answer without a message ID) or final.

Acceptance is not delivery. We deduplicate matched receipts with `ON CONFLICT(provider_event_id) DO NOTHING`, and every later statement for that receipt acts only while it is unprocessed (`processed_at IS NULL`). We advance the outbox row only when the event is newer by `julianday` time, or equally new with a status that ranks at least as high as the stored one. After a bounce or complaint, we add the address to the suppression list we check before the next send. We commit the receipt, the guarded updates, any suppression and the processed marker in one write batch, so no processed marker lands ahead of the effects it records.

## The email that disappeared behind an acknowledgement

Between 7 and 21 September 2026, report deliveries started as a test send, or as a backfill of a missed run, reached the queue and were never sent. Other families were not affected. On 21 September we caught one in production: we started to process the message, logged an error and acknowledged it in 0 milliseconds. Fourteen hours later the outbox row was still `enqueued`, with zero attempts.

The first cause was that we built IDs containing a character our recogniser rejected, so we could not tell which family the message belonged to. The second cause is the one that made it last two weeks. We answered an unrecognised ID by logging it and acknowledging the message, and nothing on that branch touched the database. By acknowledging, we deleted the message but left the row `enqueued`, as if work were still in flight.

In the words of the fix, that is what turned a one-character bug &quot;into a two-week &apos;the queue never delivers&apos;&quot;. We fixed it twice on the same day. We now recognise the IDs we build, and we now ask the two questions separately in the consumer, as in the loop above. For a valid envelope whose ID no family recognises, we mark an eligible row `failed` with `outbox_id_unclaimed` inside the `try`, so if that write fails the message is retried rather than destroyed while the row still looks alive.

In the regression test, we run the real consumer loop against an in-memory libSQL database built from the same migration. We test that the row reaches `failed`, duplicate delivery, rows with a provider message ID and the dead-letter path. If we cannot write the failed status, we verify that we retry and leave the row enqueued. Against the consumer as it was before the fix, three of its first five tests failed.

Queue metrics would not have shown this, because an acknowledged message is deleted from the queue whether or not we did any work for it. For the 30 days to 15 October 2026 at 10:00 UTC, Cloudflare&apos;s sampled analytics estimate that the production email queue had 41 messages written, 41 read and 41 deleted after success, and none moved to the dead-letter queue. We did not measure the queue&apos;s numbers for the incident itself, and the analytics we read have no breakdown by email family. Check queue metrics for message movement, and the outbox row and provider receipts for whether an email went out.

Two smaller incidents, both fixed, involved the provider. On staging on 10 September 2026, Resend rejected application emails because one tag value contained a character outside the set it allows in tags; we now clean tag names and values at the provider boundary. On 28 September 2026, in production we answered 503 to 363 webhook receipts that belonged to staging QA mail. Every email now carries a tag that names its environment, and we skip the other environment&apos;s receipts in each webhook.

## When this is the wrong pattern

**When you can tolerate loss before publication.** If a notification needs no atomic database write and its loss costs nothing, publish to the queue and accept at least once.

**When you have no relay.** We can let publication fail safely only if we later publish the rows left `pending`. Without a sweep, we record an email nobody sends. Build the relay with the outbox, not after it.

Our application relay and report recovery are specific implementations, not a universal sweep for the table. Recovery, exhausted-delivery handling and frozen-content cleanup need their own coverage for each email family. We have open work in those areas. The consumer and payload-freezing examples should not be read as evidence that those wider obligations are complete.

**When nobody reads `unknown_outcome`.** Writing a 409 as an unknown outcome moves the decision to a person. Someone has to investigate those rows to establish whether the provider sent the email.

**When the payload cannot be stored.** The frozen payload is personal content in your database for as long as a retry may need it. If you cannot keep it there, you need a rendering that is byte-stable across deploys, which is harder than it sounds. Plan a separate clean-up for rows that settle outside the send path, through a webhook or an unsubscribe.

## Verify this

**Reproduce the provider&apos;s rule.** In your own project, send the same `Idempotency-Key` twice with two different subjects. Resend&apos;s documentation says the second request gets a 409 with `invalid_idempotent_request`, while the same payload would get the original response.

**Test your consumer against a model of that rule.** Give the model two answers: the same key and payload return the original message ID without sending, and the same key with a different payload returns a 409. Let it accept your first attempt and lose the response, change your template, then run your consumer again on the same outbox row.

Assert three things: both requests carry the same fingerprint, the second attempt is accepted with the original message ID, and the model sent one message. A consumer that sends newly rendered fields on retry fails the first two, because its second request is refused; counting sent messages alone would not catch it. Fingerprint the final provider request, after tag cleaning and the environment tag, rather than only the replayed fields.

Extend that test with failures on either side of each acknowledgement. Stop after the domain transaction but before publication, then ask whether a relay finds the row. Let the provider accept but lose its response, then ask whether the same request is replayed. Fail the database write that records acceptance, then ask whether another attempt can reconcile the provider&apos;s answer. Finally, make a status write fail before `ack()` and verify that the message is retried. Run these against synthetic recipients and a provider model: they test the protocol without sending email. A passing queue invocation alone cannot answer any of these questions.

**What we draw on.** We read Evenhall&apos;s code as it stood on 15 October 2026, when all 67 tests in the six email test files passed at 10:48 UTC in 1.82 seconds. The queue figures come from Cloudflare&apos;s analytics for the 30 days to 10:00 UTC that day. We did not read any email or participant records from Evenhall&apos;s production database, so the article gives no counts of invitations, applications or delivered emails from Evenhall. The count for TEEI Language and TEEI Mentorship comes from TEEI&apos;s daily email measurement, which counts the rows of a copy of that platform&apos;s email log by provider and status, without reading addresses or content. The phone screens were captured on 17 October 2026 from Evenhall&apos;s public demo and the public tour of TEEI Language, and the invitation was rendered from Evenhall&apos;s template with the demo&apos;s sample names; nothing was sent. On 17 October 2026 we also read the schema of Evenhall&apos;s production database and its migration ledger, and found the frozen-payload store in place there. The same day, we ran the six email test files again together with the report-email test; all 93 tests passed, including the model tests behind the step-by-step table.

These records are TEEI&apos;s own. They are not public, and we do not publish an endpoint for them.</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Cloudflare Queues</category><category>Cloudflare Workers</category><category>Transactional Outbox</category><category>Evenhall</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>Astro Server Islands: Cache Age Is Not Data Age</title><link>https://theeducationalequalityinstitute.org/blog/static-pages-live-data-astro-server-islands-cloudflare-workers-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/static-pages-live-data-astro-server-islands-cloudflare-workers-en/</guid><description>We keep Astro pages static while handling old measurements, failed source reads and island requests that never replace the fallback.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>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](https://docs.astro.build/en/guides/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](/programmes/), [TEEI Language](/programmes/education/teei-language/), [TEEI Mentorship](/programmes/education/teei-mentorship/) and [Skills Academy](/programmes/education/skills-academy/). We examine their implementation and production responses recorded on 15 October 2026. The [Astro on Workers homepage case](/blog/our-homepage-is-a-live-system-astro-cloudflare-workers-en/) follows a separate, later implementation through the handoff to an open browser; the [Cloudflare overview](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/) 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: &apos;server&apos;`, and each programme page opts into build-time rendering with `export const prerender = true`. We serve its HTML through [Workers Static Assets](https://developers.cloudflare.com/workers/static-assets/). Our [`run_worker_first = true`](https://developers.cloudflare.com/workers/static-assets/routing/worker-script/) setting invokes the Worker even for an asset hit; the adapter can then return the file. [Worker-first and assets-first routing](/blog/worker-first-or-assets-first-routing-astro-on-cloudflare-en/) 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.*

```astro
---
const known = withoutNow(await readRecord());
---


  

```

Astro puts the fallback into the page&apos;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 `` 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.*

```astro
---
interface Props {
  variant: &apos;dateline&apos; | &apos;slip&apos;;
  known: Reading | null;
}
const { variant, known } = Astro.props;

const reading = await readRecord();
Astro.response.headers.set(
  &apos;Cache-Control&apos;,
  reading ? &apos;public, max-age=60, stale-while-revalidate=300&apos; : &apos;no-store&apos;,
);
---


```

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&apos;s inlined function checks the status and content type, then reads the body before removing the fallback:

*Adapted for publication.*

```js
async function replaceServerIsland(id, r) {
  let s = document.querySelector(`script[data-island-id=&quot;${id}&quot;]`);
  if (!s || r.status !== 200 || r.headers.get(&apos;content-type&apos;)?.split(&apos;;&apos;)[0].trim() !== &apos;text/html&apos;) return;
  let html = await r.text();
  // Only after this succeeds does Astro replace the fallback.
  // …
}
```

Table: The two failure decisions, as implemented in the islands and Astro runtime read on 15 October 2026.

| 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&apos;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&apos;s fallback in place, hiding the explanation. HTTP status describes delivery of the fragment; its HTML describes the result of reading the source.

![Diagram of one server island. The reader’s browser asks our Worker for the page, and we serve the prerendered shell with its fallback from Static Assets; the browser then asks our Worker for the island, and we read the source to render it. If the island answers with status 200 and HTML, and we finish reading its body, we replace the fallback in the browser; with any other answer, or a body we cannot read, we leave the fallback in place. A 200 can still carry a valid source reading, the build’s reading, no figure, an empty list or a sentence saying the list could not be read.](/images/blog/static-pages-live-data-astro-server-islands-cloudflare-workers/island-lifecycle.svg#wide,phone,fit &quot;We handle failure in two places: with Astro’s response check in the browser we decide whether the fallback is replaced, and in our island code we supply the HTML that replaces it.&quot;)

Source: TEEI, drawn from Astro&apos;s server island runtime and our two islands, 15 October 2026.

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&apos;s clock, then the cache&apos;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.*

```ts
let memo: { at: number; reading: Reading } | null = null;
const MEMO_MS = 60_000;

export async function readRecord(timeoutMs = READ_DEADLINE_MS): Promise {
  const nowMs = Date.now();
  if (memo &amp;amp;&amp;amp; nowMs - memo.at &amp;lt; MEMO_MS) return toReading({ ...asBody(memo.reading) }, nowMs);
  try {
    const r = await fetch(UPSTREAM, {
      headers: { accept: &apos;application/json&apos; },
      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&apos;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&apos;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&apos;s start time.

That check ends at rendering. A browser reusing the island&apos;s HTML under `max-age=60, stale-while-revalidate=300` does not rerun it. Likewise, stripping “now” from the build&apos;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](https://language.theeducationalequalityinstitute.org/api/lc/public-stats) 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.*

```astro
const reading = await readSessions();
Astro.response.headers.set(
  &apos;Cache-Control&apos;,
  reading.outcome === &apos;source_unavailable&apos; ? &apos;no-store&apos; : &apos;public, max-age=60, stale-while-revalidate=300&apos;,
);
```

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](https://developers.cloudflare.com/workers/cache/limitations/) 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&apos;s age along, so the two lifetimes can accumulate before the rendered HTML&apos;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&apos;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.

![Diagram of two groups of caches. Rendered responses: the page shell is cached at Cloudflare’s edge and revalidated by the browser; we set the island’s HTML policy for the browser cache, 60 seconds fresh and five more minutes of stale reuse while it revalidates; Workers Caching was off in the configuration we examined, and the island responses we captured carried no cf-cache-status header, so they showed no edge caching. Source data inside the Worker: for 60 seconds we keep the parsed reading in a memo for the live record, and the raw source body in a memo and in the Cache API for Skills Academy.](/images/blog/static-pages-live-data-astro-server-islands-cloudflare-workers/island-caches.svg#wide,phone,fit &quot;Who caches what: rendered responses above, source data inside the Worker below. On 15 October 2026 we measured only the shell’s edge hits and the islands’ missing cf-cache-status.&quot;)

Source: TEEI, from response headers measured on 15 October 2026 and the code of the two islands.

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](https://developers.cloudflare.com/workers/cache/) honours the Worker&apos;s response policy when enabled in its configuration; setting `Cache-Control` alone does not enable it. Separately, Astro supports an [adapter cache provider](https://docs.astro.build/en/guides/caching/#adapter-cache-providers). 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&apos;s plain `Cache-Control` would not prevent the default. Enabling a provider therefore needs a decision about the site&apos;s other responses, too.

The [Cache API](https://developers.cloudflare.com/workers/cache/limitations/) 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&apos; 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.

![Chart of time to first byte from Oslo on 15 October 2026 for the shell, the island and the source, measured as separate requests one after another. The prerendered page shells returned their first response byte in a median of 46 to 49 milliseconds, a byte that can belong to an HTTP 103 Early Hints response rather than the final HTML, the server islands in 55 to 60, and the two sources in 166 and 215, with the slowest source read at 1,858 milliseconds.](/images/blog/static-pages-live-data-astro-server-islands-cloudflare-workers/ttfb.svg#wide,phone,fit &quot;Time to first byte for the shell, the island and the source, measured as separate, sequential requests, twenty each from Oslo on 15 October 2026. This is not a browser waterfall. The bar is the median and the line runs to the 90th percentile.&quot;)

Source: TEEI, measured with curl from Oslo on 15 October 2026, 10:50:00 to 10:51:28 UTC, twenty rounds with a three-second pause between them. Time to first byte is curl&apos;s `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&apos;s guide](https://docs.astro.build/en/guides/server-islands/#caching) describes the threshold in bytes. Our captured URLs were 96 to 196 characters; we did not measure the preload&apos;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&apos;s advice on reusing a key](https://docs.astro.build/en/guides/server-islands/#reusing-the-encryption-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](https://developers.cloudflare.com/workers/cache/configuration/) 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.*

```astro
---
import Clock from &apos;../components/Clock.astro&apos;;
export const prerender = true;
---


  &lt;p&gt;Reading the clock…&lt;/p&gt;

```

Create `./src/components/Clock.astro` with `fail` initially set to `false`:

*Example for your own project.*

```astro
---
const fail = false;
await new Promise((resolve) =&amp;gt; setTimeout(resolve, 3000));
Astro.response.status = fail ? 503 : 200;
Astro.response.headers.set(&apos;Cache-Control&apos;, &apos;public, max-age=60&apos;);
const renderedAt = new Date().toISOString();
---

&lt;p&gt;Rendered at {renderedAt}&lt;/p&gt;
```

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 `` in the page head and read the headers with `curl -s -D - -o /dev/null &apos;&apos;`, converting HTML&apos;s `&amp;amp;` back to `&amp;amp;`. Check cache headers separately from the source&apos;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 | `&apos;server&apos;`, 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](/blog/our-homepage-is-a-live-system-astro-cloudflare-workers-en/) through a fragment&apos;s handoff to browser updates. For the response contract behind that connection, continue with [handling WebSocket upgrades before Astro](/blog/websockets-through-astro-at-the-worker-boundary-en/).</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Astro</category><category>Server Islands</category><category>Cloudflare Workers</category><category>Caching</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>One Nonce, Two Inputs: Fixing React Router Streaming</title><link>https://theeducationalequalityinstitute.org/blog/streaming-react-router-under-a-strict-content-security-policy-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/streaming-react-router-under-a-strict-content-security-policy-en/</guid><description>We restored loader data with one nonce input. For late Suspense content, we needed the same nonce in another place.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>With a nonce-based CSP, we restored loader data for hydration by passing a nonce to React Router. We saw the volunteer home page pass a manual check, yet on the learner home on staging we still saw client rendering with React error #419. There, we had content waiting behind a Suspense boundary after the shell had gone out. To finish that boundary on the server, we also needed the same nonce in React&apos;s render options. We had two rendering inputs for inline scripts in one response. We had fixed one and missed the other.

Both pages belong to the platform behind [TEEI Language](/programmes/education/teei-language/) and [TEEI Mentorship](/programmes/education/teei-mentorship/): one [React Router 8](https://reactrouter.com/) application on Cloudflare Workers. [The lead article in this series](/blog/one-codebase-two-programmes-react-router-8-cloudflare-workers-en/) covers how one codebase serves both programmes, and [how TEEI runs its platforms on Cloudflare Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/) covers the platform underneath.

**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.

## Two writers, one document

A nonce-based policy is simple to state. For each response we make a fresh random value, name it in the `Content-Security-Policy` header, and write the same value into the `nonce` attribute of every inline `</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>React Router</category><category>Content Security Policy</category><category>Streaming SSR</category><category>Cloudflare Workers</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>How We Join React Router Errors Across Browser and Worker</title><link>https://theeducationalequalityinstitute.org/blog/tracing-a-failure-from-react-router-to-workers-with-sentry-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/tracing-a-failure-from-react-router-to-workers-with-sentry-en/</guid><description>We can capture an error and still struggle to follow the failure. We compare a shared trace with a request ID across browser and Worker.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>TEEI&apos;s free programmes connect people from Ukraine, and women from Latin America and the Caribbean, with volunteers who help them practise a language and build a career. When a participant cannot finish something on one of our platforms, an error in Sentry is where we start, not where we stop. We have to follow what happened from what they pressed on the page to the Worker that answered, and to the log line that Worker wrote, or did not write.

We can render a React Router error boundary and still report nothing to Sentry. We have already caught the loader error inside React Router before it can escape to the Worker&apos;s Sentry wrapper, so we capture it in `handleError` instead. Then we still have to connect that server error to the browser event and the Worker log. We use a shared trace on one platform and a request ID on another. With the trace, we can follow the failure across browser and server. With the ID, we join records across searches.

**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.

## One failed request, followed end to end

On 3 October 2026, a learner on TEEI Language, our language-learning platform, pressed one of the club formats under &quot;Not finding the club you want?&quot; at the foot of the club page, and read &quot;That did not send. Please try again.&quot; Each such press tells the volunteers who decide what to host which kind of club a learner would come to: under &quot;People have asked for&quot;, they see one line for each club format and language with the number of people who asked, and a button to host it. This one was never recorded, and this is how the records led us to the cause.

Table: How we followed one failed club request on TEEI Language, from the incident record of 3 and 4 October 2026.

| Where we looked | What it established |
|---|---|
| The browser event in Sentry, reported by the form that sent the request | Which action failed, on which page and release, that we had classed it as blocking the learner, and the server&apos;s answer: &quot;Invalid request&quot; |
| The request ID on that event | The Worker request that had served the page, not the request that failed |
| Workers Logs, searched with that ID | No line from the club-request route |
| The route&apos;s code | One check answered 400 &quot;Invalid request&quot; without writing a log line: the check of the request&apos;s shape |
| The learner&apos;s profile, read without changing it | No learning language, so the page had offered the press with an empty language, and our form reader had dropped the empty field before the check |

The missing log line was the finding. Other refusals on that route wrote a warning with their reason; this one wrote none, so the only record of why the request failed was the browser&apos;s report. That report carried a request ID, but it was the document&apos;s: the ID of the request that served the page, which we put in the page&apos;s settings, as we describe below. The failed POST had an ID of its own, and the form did not report it. So a search with the event&apos;s ID could not have found the refusal even if the route had logged it, and we had to establish from the route&apos;s code that this refusal logged no reason.

We committed the fix the next morning and shipped it to production the same day. A press without a language now sends nothing; the page opens the language choice and asks &quot;Which language?&quot;. The route now logs a refused request shape with the names of the refused fields, never their values. In our regression tests, switching off either half fails exactly the new test for that half. The form still reports only the document&apos;s ID, which is the limit of a request-ID join that we come back to under &quot;What each choice costs&quot;.

![Two crops of a learner&apos;s club page in TEEI Language on a phone, from the demo platform with sample data. First, with a language set: under Past clubs, a club the learner took part in, then the ask, Not finding the club you want?, with five formats to press and the learner&apos;s language, English. The press goes from the browser to the Worker&apos;s club-request route, and approved volunteers see it under People have asked for. Second, with no language: after a format was pressed, the language pill reads Which language? and its panel has opened above a search field. Since 4 October 2026 the page sends nothing in this case; on 3 October the request went out with an empty language and the route refused it without a log line.](/images/blog/tracing-a-failure-from-react-router-to-workers-with-sentry/club-ask.svg#wide,phone,fit &quot;One press on the ask at the foot of a learner&apos;s club page in TEEI Language, with and without a language, and where each runs. Sample data.&quot;)

Source: TEEI Language demo platform, 17 October 2026.

TEEI Language helps adult learners practise through one-to-one conversations with approved volunteers and through group sessions, and a club request is how a learner asks for the group they would join. In a separate film, Oksana K., a TEEI Language participant, describes that choice.

![Oksana K., a TEEI Language participant](https://r2.theeducationalequalityinstitute.org/videos/cockpit/square/oksana-k-1.mp4?v=2026-09-22b#square &quot;Oksana K., a TEEI Language participant, on choosing individual or group meetings, at the times and on the days that suit her.&quot;)

Source: TEEI, a film recorded with Oksana K.&apos;s consent; this square cut rendered on 22 September 2026.

The rest of this note is the machinery behind the table above: where we capture an error, how we name the build it came from, and how we join the browser&apos;s record to the Worker&apos;s.

## Installed is not the same as reported

We build TEEI Language and TEEI Mentorship on one platform, and Evenhall, the workspace where a partner company&apos;s managers see what their employees did through TEEI&apos;s programmes and turn it into a report they can file, as a separate app. We run all three as React Router 8 apps on Cloudflare Workers, as we describe in [How TEEI Runs Its Platforms on Cloudflare Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/), with `@sentry/cloudflare` on the Worker and `@sentry/react` in the browser.

We have learned from both directions that &quot;Sentry is installed&quot; and &quot;our errors reach Sentry&quot; are different claims. On Evenhall, in a comment in the server entry, we recorded zero error events in the project between its creation and 9 July 2026. We rely on the comment we wrote when we found the problem; we have not rechecked Sentry for this note. We had no `handleError` export, and we initialised Sentry per request with a wrapper of ours that captured events but never delivered them. We fixed both at once, by exporting `handleError` and moving initialisation to `withSentry` on the Worker&apos;s default export, so we cannot say how much of the silence each one caused.

From our admin app, a React Router app our own staff work in, we sent the opposite: 8,584 events in 14 days, 83% of all Sentry traffic in our organisation, according to the commit in which we fixed it on 1 September 2026. People running vulnerability scanners had asked for paths that do not exist, and we reported each request as `Error: [object Object]`.

## Where we catch an error

When a loader or action fails, we let React Router catch the error and render the nearest error boundary. With `withSentry` we capture only errors that escape the Worker&apos;s handler, so a team can run a Worker with `withSentry` and nothing else for months and get no report from its routes. For this we export `handleError` from `entry.server.tsx`, the hook described in React Router&apos;s documentation for error reporting ([error reporting](https://reactrouter.com/how-to/error-reporting), [entry.server](https://reactrouter.com/api/framework-conventions/entry.server.tsx)). We can also receive errors from the initial server render there.

On TEEI Language and TEEI Mentorship we explicitly capture errors on three paths outside the automatic capture. This is the first:

*Adapted for publication.*

```ts
export const handleError: HandleErrorFunction = (error, { request, context, params }) =&amp;gt; {
  const log = getLogger(context);
  const path = routerErrorPath(request, params); // We replace dynamic params with :name.
  // We treat 4xx ErrorResponses as client faults: we log a warning and never capture them.
  if (isRouteErrorResponse(error) &amp;amp;&amp;amp; error.status &amp;lt; 500) {
    log.warn(&apos;router.client_error&apos;, { method: request.method, path, status: error.status });
    return;
  }
  log.error(&apos;router.error&apos;, error, { method: request.method, path });
  captureError(error, {
    tag: &apos;router.error&apos;,
    programme: sentryProgramme(request, context),
    extra: { method: request.method, path },
  });
};
```

We record the path with its recognised dynamic segments replaced by their parameter names, or a single placeholder when we cannot complete that replacement, and we scrub the whole event again before we send it. We attach the programme tag only when we can resolve it, and capture the error without the tag when we cannot. We learned that order on 23 July 2026, when we threw while resolving the programme for a scheduled job and captured that error instead of the original. A tag must never replace the error it describes: in a regression test we now assert that we capture the original `Error` object without a programme tag.

On the second path, we stream HTML through React&apos;s `renderToReadableStream` and receive later rendering errors in its `onError` callback, where we log and capture them with their own tag. Before we send the `Response` we can still set the status to 500. After that, we still capture the failure, but we cannot change the HTTP status the browser already has. We describe how we run that stream under a strict content policy in a separate note.

On the third path, we catch errors thrown in middleware that runs before React Router, and in the request handler, in our Worker&apos;s `fetch` handler, and return our own JSON 500 response. Because we return rather than rethrow, those errors never escape to `withSentry`, so we capture each one explicitly in that catch.

![Diagram of how we handle errors in four paths through our React Router app on a Cloudflare Worker. We report loader and action errors through handleError before we render the error boundary, capture later streaming render errors in onError, catch errors that escape React Router in the Worker’s fetch handler and capture them explicitly, and only log router-generated 400, 404 and 405 errors.](/images/blog/tracing-a-failure-from-react-router-to-workers-with-sentry/where-we-catch.svg#wide,phone,fit &quot;Where we catch an error on TEEI Language and TEEI Mentorship. We use withSentry for errors that escape the Worker’s handler, and capture explicitly in handleError, onError and the fetch catch. Code as read on 15 October 2026.&quot;)

Source: TEEI, drawn from our platform code on 16 October 2026.

## What reaches handleError

We caused the admin flood with a guard for `instanceof Response`. We read in React Router&apos;s documentation of `handleError`: &quot;Note that this does not handle thrown Response instances from your loader / action functions.&quot; We found that true for a `Response` you throw yourself, and not true for errors generated inside the framework.

We read the installed `react-router` 8 package rather than run an experiment. For document and data requests, we receive an `ErrorResponse` in `handleError` only when it carries an underlying error; in the package we found the condition `!isRouteErrorResponse(err) || err.error`. The 400, 404 and 405 responses generated inside the framework, for a route with no match or a route without a loader or action for the method, carry that `error`, so we receive them in the hook. For an unmatched route we did not get a `Response` instance, so we let it through our admin guard, and in the capture we turned the object into a string. We turned every request a scanner made for a path that does not exist into an error.

We now use the check in the `handleError` above. Routine client faults stay in Workers Logs as warnings, and application failures go to Sentry: every 5xx `ErrorResponse` and every other thrown error, apart from one refusal generated inside the framework that we record as a warning.

Our own throws behave differently. When we throw a `Response`, or a `data(...)` with a payload that is not an `Error`, we receive nothing in `handleError`. When we throw a `data(...)` carrying an `Error`, we receive that `Error` itself in document and data requests, whatever status we gave it; in a resource-route request we get a `Response` instead, and no call to `handleError`.

## One release name on both sides

We can use an error only if we can tell which build it came from. In a Worker we can read its version ID through the [version metadata binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/version-metadata/), and from version 10.35.0 of `@sentry/cloudflare` we get that ID as the release when we set none ([Sentry&apos;s Cloudflare troubleshooting page](https://docs.sentry.io/platforms/javascript/guides/cloudflare/troubleshooting/)). At build time we cannot know a version ID assigned on upload, so nothing we file under the release during the build can use it.

So in both apps we name the release at build time and use the version ID only as the last fallback. On TEEI Language and TEEI Mentorship we prefer an explicit release name, then fall back to the source commit of the build candidate we froze for deployment, and finally the checkout&apos;s commit. We compile the same build constant into the browser bundle, so when that constant is present, we send browser and Worker events with the same Sentry release string.

## Joining the layers

Once we capture an error and name its release, we still need to see that the records on each layer belong to one failure. On Evenhall we use Sentry&apos;s distributed tracing: we render `sentry-trace` and `baggage` into the page on the server and continue that trace in the browser. On TEEI Language and TEEI Mentorship we use a request ID instead. We found no explanation for the different choices in our commits, so we describe both and say what each costs us.

### Evenhall: the server&apos;s trace in the page

On Evenhall, we return the trace data of the current request from the root loader and render it into `` in the root layout.

*Verbatim excerpt.*

```ts
export function browserTraceData(): Record {
  const active = getActiveSpan();
  const data = getTraceData(active ? { span: getRootSpan(active) } : undefined);
  return {
    &quot;sentry-trace&quot;: data[&quot;sentry-trace&quot;] ?? &quot;&quot;,
    baggage: data.baggage ?? &quot;&quot;,
  };
}
```

*Adapted for publication.*

```tsx


```

Note `getRootSpan`. We run the root loader inside two child spans of our own, and in an earlier version we read the active span instead. When we cannot render Evenhall&apos;s public demo live, we serve its last good copy. We must replace its trace values on every request, or we put every visitor on the storing request&apos;s trace. We find the values to replace by the ones we rendered the page with. When we read them from a child span, we put a span ID into the page that we never matched when we later read the page in the Worker, and in the words of the fix on 30 September 2026, &quot;the sentry-trace swap hit 0 slots and every copy kept the storing request&apos;s trace&quot;. We now read the root span in both places, and we reject the swap if an old trace pair would remain in the page.

We test the helper inside a child span. We require the complete root trace data, and check that a direct read of the child shares the trace ID but differs from the helper&apos;s value. If we compared trace IDs alone, we would miss this regression. We found a warning about caching such pages in Sentry&apos;s documentation, under [&quot;Caching HTML That Contains Tracing Meta Tags&quot;](https://docs.sentry.io/platforms/javascript/guides/cloudflare/tracing/distributed-tracing/custom-instrumentation/).

In Evenhall&apos;s browser code we turn off the tracing integration&apos;s automatic page-load and navigation spans (`browserTracingIntegration({ instrumentPageLoad: false, instrumentNavigation: false })`) and start the page-load span ourselves, from values read before hydration:

*Verbatim excerpt.*

```ts
function startPageLoadSpan(config: BrowserTelemetryConfig): void {
  const client = Sentry.getClient();
  if (!client) return;
  Sentry.startBrowserTracingPageLoadSpan(
    client,
    {
      name: location.pathname,
      startTime: performance.timeOrigin / 1e3,
      attributes: {
        &quot;sentry.source&quot;: &quot;url&quot;,
        &quot;sentry.origin&quot;: &quot;auto.pageload.browser&quot;,
      },
    },
    { sentryTrace: config.sentryTrace, baggage: config.baggage },
  );
}
```

We give the reason in the comment above it: if we read the tags from the live document after rendering a root error boundary before load, we have already replaced ``, and we would start the page-load trace on its own in exactly the case where we most want it joined to the server&apos;s. We configure browser tracing to propagate only to requests to the page&apos;s own origin (`tracePropagationTargets`); we have read that in the configuration, not observed the headers on the wire.

![Sequence diagram of how we read the request’s root trace data in Evenhall’s Worker, render it into meta tags in the page head and start the browser’s page-load span from those tags. For a stored copy of the demo we swap the values on every request. We show propagation on the browser’s same-origin requests as configured, not observed.](/images/blog/tracing-a-failure-from-react-router-to-workers-with-sentry/evenhall-trace.svg#wide,phone,fit &quot;How we carry one trace from Evenhall’s Worker into the browser. We show placeholders only, no real trace values. Code and configuration as read on 15 October 2026.&quot;)

Source: TEEI, drawn from our platform code on 16 October 2026.

Anyone can see the tags from outside, in the source of [Evenhall&apos;s public demo](https://evenhall.theeducationalequalityinstitute.org/demo/). Our measurement did not start at that link. At 13:09 UTC on 15 October 2026 we requested `https://cockpit.theeducationalequalityinstitute.org/demo/` twice, four seconds apart, with `curl -L`, which follows redirects; we did not record the address that finally answered. In each load we found one `sentry-trace` and one `baggage` tag, with different trace IDs and the sampled flag set. The two responses carried different trace metadata. That demonstrates replacement for those observed responses, not that the HTML bypassed all caching. On your own project, compare two loads on each cache path you use.

### TEEI Language and TEEI Mentorship: one request ID on every layer

On the language and mentoring platform we join the layers with an ID we create in the Worker for every request, and render no trace into the page.

With `withSentry`, we run each `fetch` inside its own isolation scope, so a tag we set there belongs to that request alone. We set the request ID as a tag on that scope, write it into every log line of the request, and return it as a response header. In the root loader we put it into the page&apos;s settings, under a name we chose to mark it as the document&apos;s ID, because we send each later navigation as a request of its own, with its own ID. When the browser SDK starts and the document request ID is present in the page&apos;s settings, we attach it as a tag to the browser&apos;s events.

Where that tag is present, we can join a browser event in Sentry to the server events of the request in which we served the document, and then find that request in Workers Logs by the same ID. You can see the arrangement from outside: in the front page&apos;s source, which we fetched at 13:09 UTC on 15 October 2026, we found no `sentry-trace` and no `baggage` meta tag, and we found the document&apos;s request ID and commit identity in the page settings. In the browser we add no tracing integration and set no propagation targets, so from reading the code we send no `sentry-trace` or `baggage` headers on the browser&apos;s requests; we have not observed that on the wire.

We still trace inside the Worker. With React Router 8&apos;s [instrumentation API](https://reactrouter.com/how-to/instrumentation) we open a span for the request and every loader, action and middleware, named by route pattern, never by the URL: we rename the request span to its method and pattern once the handler returns, and name the others by operation and pattern. When we are recording a span, we write its IDs onto the matching log lines:

*Verbatim excerpt.*

```ts
function traceFields(span: Sentry.Span): Record {
  if (!span.isRecording()) return {};
  const context = span.spanContext();
  return {
    sentryTraceId: context.traceId,
    sentrySpanId: context.spanId,
  };
}
```

So from the log line for a completed route we can reach its server trace when we recorded one. We set one trace sample rate per environment, for the Worker and the browser alike. We report these as configured sampling rates, not measured coverage; we have not measured the share of requests for which we obtain complete traces. We sample error events separately, so with a lower trace rate we get fewer complete traces, not fewer errors, and the request ID is on every request either way.

![Diagram of one request ID that we create in the Worker and attach to its Sentry scope, its log lines, its response header and the page’s settings, and that we then attach to the browser’s own Sentry events when it is present in those settings. We join the events in Sentry by that ID and look the request up in the logs. We send no trace header from the browser to the Worker.](/images/blog/tracing-a-failure-from-react-router-to-workers-with-sentry/request-id.svg#wide,phone,fit &quot;On TEEI Language and TEEI Mentorship we join browser and Worker events by one request ID instead of trace headers.&quot;)

Source: TEEI, drawn from our platform code on 16 October 2026.

### What each choice costs

With a request ID we need no header on the browser&apos;s requests, no CORS consideration and no tracing SDK in the browser at page load. That suits the language platform, where we load browser telemetry separately from the page and correlate the reports it delivers with server logs through the request ID. In return we get no single trace from click to loader in Sentry&apos;s trace view: we follow an ID across searches instead. And an ID names only the request we gave it to. In the club request at the start, the browser&apos;s report named the page&apos;s request, so the event could not take us to the refused request; we had to establish separately, from the code, that the route logged no reason for refusing it.

On Evenhall our code continues the server&apos;s trace in the browser, so a page load can join one trace; we have not read such a trace back from Sentry for this note. We pay for it by having to make sure that we never serve HTML containing trace tags twice unchanged. A request ID in cached HTML has a related limit: it still names the request in which we rendered the cached document, so refresh it if you need the request that served it now. If you need a joined page-load trace, read the metadata before hydration and refresh it whenever you serve stored HTML.

## The hook that never saw transactions

Before we join records, we have to know what each one carries. We can carry data from a person&apos;s visit in our telemetry: in URLs and query strings, and in the names and messages we show on our pages. On 30 September 2026 we learned from independent QA that our error scrub on TEEI Language and TEEI Mentorship did not cover sampled transactions from either the Worker or the browser, so a sampled transaction could carry such data to Sentry. We put the cause in one line in the commit message: &quot;`beforeSend` only ever sees error events, so scrubErrorEvent never ran on it, and neither the Worker nor the browser had a beforeSendTransaction. Measured by independent QA with the real SDKs&quot;. In the browser it is worse than it sounds, because there we send the full page URL as the transaction&apos;s `request.url`.

So on TEEI Language and TEEI Mentorship we now give each of four kinds of data its own hook. We pass errors through `beforeSend`, transactions through `beforeSendTransaction`, logs, which we send only from the Worker, through `beforeSendLog`, and spans through `beforeSendSpan`. We send errors, transactions and logs through our shared redaction of strings. For spans we remove attributes by name, those whose names mark them as request metadata such as a URL, cookies or headers, and for a router span with a recognised route pattern we rebuild the description from that pattern and the method or operation. We set `sendDefaultPii` to false on both sides.

![Diagram of four kinds of Sentry data on TEEI Language and TEEI Mentorship and the hook we pass each through. We send errors, transactions and logs through one shared redaction of strings, remove transaction fields of an unknown or unsupported shape, and drop a transaction only when scrubbing fails. For spans we remove attributes by name, those whose names mark them as request metadata such as a URL, and, for a router span with a recognised route pattern, rebuild the description from that pattern and the method or operation.](/images/blog/tracing-a-failure-from-react-router-to-workers-with-sentry/four-hooks.svg#wide,phone,fit &quot;On TEEI Language and TEEI Mentorship we give every kind of event its own hook. We added the transaction hook on 30 September 2026, after we learned from independent QA that we could not scrub transactions in beforeSend. Code as read on 15 October 2026.&quot;)

Source: TEEI, drawn from our platform code on 16 October 2026.

With the fix we gave transactions their own scrub. We send the transaction name, the request URL, the query string, every span description and every string in span data through structural rules and then the shared redaction. We replace every query value except a few known-safe keys with `[redacted]`, and a fragment with `#[redacted]`. In the transaction&apos;s request object we keep only `url`, `method` and `query_string`. We remove fields of an unknown or unsupported shape and keep the rest of the transaction; if scrubbing fails, we drop the whole transaction.

We did not catch the leak with unit tests of a scrub function; we had missed which kind of event we pass to which hook. So we now test at the transport boundary, through the real browser SDK in jsdom and through the Worker&apos;s own options with the real `withSentry`: credentials must be absent, removal must hold for our actual route table, and any unreviewed top-level transaction field fails the test. On 30 September 2026, with the Worker hook removed, two of the four Worker tests we had then failed.

On 15 October 2026 we read the commit identity we return in a response header from production, and we found the fix in its source commit. We have not verified the uploaded bundle or measured the fix&apos;s effect in Sentry.

## Verify capture, correlation and scrubbing separately

A rendered error boundary, an accepted Sentry event and a joined trace are three different results. Test them separately in a project you control, with synthetic data. Our code readings and dated public-page checks do not establish end-to-end delivery for every failure path.

Table: A verification matrix for the mechanisms in this note. It is a proposed procedure for your test project, not a new production run at TEEI.

| Deliberate input | Inspect at the boundary | What should be established |
|---|---|---|
| Loader throws an Error | Router hook and SDK transport | The original error reaches explicit capture despite the rendered boundary |
| Framework-generated unmatched route | Hook and log outcome | The 4xx object is classified as a routine client fault |
| Late stream render fails after response headers | Render callback and captured event | The failure is reported even though the sent HTTP status cannot change |
| Cached HTML served twice | Complete trace metadata in both responses | A storing request&apos;s metadata is replaced on each serving path |
| Browser navigates after the initial document | Document ID and navigation request ID | The document tag is not mistaken for the failing navigation&apos;s ID |
| Synthetic query value in each supported event kind | Actual SDK transport payloads | Each event kind reaches its own scrub hook |

Use an in-memory transport for the capture and scrubbing checks before sending any event. Removing the relevant hook should make the same assertion fail; otherwise the test may only prove that no telemetry was sent. A transport test still does not prove ingestion, indexing or source-map resolution in Sentry. Those need a separate, dated readback of the synthetic event after an authorised send.

## What we would keep

If we started again, we would keep three habits. We capture errors where we let React Router catch them, not only where they escape the Worker. We name the release at build time and compile the same string into both sides. And we decide before an incident how we will connect the browser&apos;s record to the Worker&apos;s: a shared trace if we can keep it fresh in every page we serve, a request ID if we would rather follow one value across searches.

On both platforms we use `@sentry/cloudflare` and `@sentry/react` directly and wire `handleError`, the instrumentation and the meta tags ourselves, not `@sentry/react-router`. We write more code that way, and we can read that code when we miss an event. If you start fresh, look at Sentry&apos;s [React Router guide](https://docs.sentry.io/platforms/javascript/guides/react-router/) first.

Session Replay is separate from this error and trace comparison. In the configuration we read on 15 October 2026, both programmes set session replay sampling to zero everywhere and error-triggered replay sampling to zero in production. The presence of replay code does not mean that these production sessions are being recorded.

We read the code and configuration in this note on 15 October 2026, on React Router 8 and the version 10 Sentry SDKs.</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Sentry</category><category>React Router</category><category>Cloudflare Workers</category><category>Observability</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>Typed Pings and Failure as Data in React Router</title><link>https://theeducationalequalityinstitute.org/blog/updating-one-number-without-reloading-every-route-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/updating-one-number-without-reloading-every-route-en/</guid><description>We refresh one unread count without rerunning the page&apos;s loaders. To keep the page after a rejected background panel load, we return failure as data.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>We used to answer an incoming message by asking every matched React Router route for fresh data. We added typed pings and used a fetcher for the unread count, and away from the messages page we now refresh the count and the bell panel without rerunning the page&apos;s loaders. We still had to contain failure: after a rejected background read of the bell panel, we showed an error screen in place of the whole page. For the failures we intend to contain, we now retry once in a `clientLoader` and return an unavailable body if the retry fails. We keep the page and show that we could not load the panel.

We can refresh one unread count without rerunning the page&apos;s loaders and still lose the page to a failed background load. We follow that gap, between the scope of a refresh and the scope of its failure, through the shared React Router 8 codebase that runs TEEI Language and TEEI Mentorship on [our Cloudflare Workers platform](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/).

**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 a message used to cost

In the app shell&apos;s loader we read, for every page, the person, their role and programme, their timezone, the two unread counts, review and waiting-list flags and the next session; in each child route we load more. When we notify a person of an incoming chat message, we write a row for the bell and send a ping over the person&apos;s live socket. Until a change we committed on 31 July 2026, we answered that ping in the shell with `revalidate()`, so we re-ran the shell loader and the page&apos;s loaders in every open tab of the recipient. In that commit we estimated about 30 database reads per incoming message, per open tab on the home page. That is our estimate, not a measurement: we did not measure database reads in production before or after the change, and we have no figure for what the fix saved.

When we call `revalidate()`, we request fresh data from every matched route unless we decline through `shouldRevalidate`. When we call `fetcher.load(href)`, we run the loader of the one route at `href`, without a navigation and without a page revalidation; by default React Router revalidates that fetcher again only when we complete a successful action or call `revalidate()`. We could not choose the narrow load from the ping, because the ping did not say what had changed.

## How we mark what changed

We had deliberately kept the ping for a new bell row close to empty, with no title, body or account id, because in the browser we re-read the content through its own loader. We added one field, the notification&apos;s type code, and kept it optional.

*Adapted for publication.*

```ts
// A notification of any type was created for this account. No title, no body, no
// account id. The type code (for example &apos;message_received&apos;) is optional: from an
// older server we receive none, and we then fall back to a global revalidate.
| { type: &apos;notification&apos;; notificationType?: string }
```

Optional is the important word. During a deploy we do not switch every server and every open tab at the same instant, so we can receive a ping without a type from an older server version. We read a ping without a type as &quot;something changed, I will not say what&quot;, and we revalidate everything. In our protocol tests we keep both shapes valid: the bare ping, and a typed one that carries the type code and nothing else.

We handle two event types for a message. When we save a message, we send a `msg_activity` event with the activity `message` to the other person in the conversation. In a later delivery step we decide whether to notify that person; when we do, we write the bell row, send `notification` with the type `message_received`, and send `msg_activity` again. We load the count for both message event types. For other notifications, such as a booking being accepted or a reminder, we keep global revalidation because they can change the page.

*Adapted for publication.*

```tsx
const shellRevalidator = useRevalidator();

useLiveSocket(true, (message) =&amp;gt; {
  if (message.type === &apos;notification&apos;) {
    if (message.notificationType === &apos;message_received&apos;) {
      refreshUnread();
      return;
    }
    // Any other type, or a ping without one: deliberately global.
    shellRevalidator.revalidate();
    return;
  }
  if (message.type === &apos;msg_activity&apos; &amp;amp;&amp;amp; message.activity === &apos;message&apos;) {
    refreshUnread();
  }
});
```

We omit how `refreshUnread` loads the count, the checks on each fetcher&apos;s state and the error reporting around each call.

![Sequence diagram of one chat message. We send an activity ping to the recipient’s tab when we save the message, and if we notify the recipient later, a notification ping and a second activity ping. For both event types we load a small unread-count route through a fetcher and a debounced bell panel route; we do not rerun the page’s loaders, and how many loads we make depends on when the events arrive. A dashed side path shows that for a ping without a type, or with any other type, we request fresh data from every matched route, and we may skip individual routes through shouldRevalidate.](/images/blog/updating-one-number-without-reloading-every-route/message.svg#wide,phone,fit &quot;What we load in a recipient’s tab for one chat message, away from the messages page. Drawn from our code, 15 October 2026.&quot;)

Source: TEEI, drawn from our platform code, read on 15 October 2026.

## One number, one route

We serve the count from a resource route, a loader with no component, from which we return the unread count for each of the person&apos;s two roles, as a learner and as a volunteer, and their sum. We use the same queries in the shell loader and resource route, including how we fold group chats into each role&apos;s count. That keeps the counting rules aligned, but the two are separate reads at different moments, and they can differ. We let the fetcher result override the loader&apos;s values when it holds both counts as numbers. We clear the override when loader data changes. We replace it when fetcher data changes and contains both counts. Where the badge and the page must show the same reading, we would use one loader result for that part of the screen rather than a narrow refresh.

We refresh the bell on the same events, through a fetcher of its own and its own panel route, and we debounce those reads, restarting the wait when another ping arrives. Away from the messages page, we refresh the count and bell panel without rerunning the page&apos;s loaders. How many requests that makes depends on when the events arrive. On the messages page itself we still revalidate the open conversation through its own socket, the one place where a message does change the page.

In the commit of 31 July 2026 we recorded one development run: &quot;three messages, three scoped unread calls, zero page revalidations, badge 1-2-3.&quot; That run counts the unread calls only, and we have no measured baseline from before the change.

## How we lost the page to a background read

The bell is where the narrow refresh went wrong. Until a change we committed on 1 August 2026, we managed its panel with raw `fetch()` calls. In that change we moved the panel to a loader on its own route, read through a fetcher. From then on, when we failed to load the panel in the browser, we went down the router&apos;s fetcher error path. We load the panel on focus, on visibility, on every navigation and on socket pings, so a person sitting on any page generated background loads they never asked for, and one network blip on one of them was enough. In the words of the fix&apos;s commit message: &quot;the whole page turned into the error box over a background read.&quot;

By 5 October 2026 our error tracking had 153 events of this kind, and in every event we read, the failing request was the panel&apos;s data request, one to twenty seconds after a successful one. We committed the fix that day. We do not know when the first event occurred. We have code history for the move to a fetcher, not a measured cause.

We had already handled the server side: when a database read fails there, we return a declared &quot;could not read&quot; body with a 503 status, and in the bell we render it as such. What remained was the load that fails in the browser, where we have no body from our server to show and see only a rejection.

## Why a fetcher cannot fail locally

We narrowed the refresh, but still reached the page&apos;s error boundary when a fetcher load threw. To us a fetcher looked like a local request with state attached. Its failure path is not local. In React Router 8.4.0, an error from an explicit `fetcher.load()` call reaches this function in the router:

*Verbatim excerpt.*

```js
	function setFetcherError(key, routeId, error, opts = {}) {
		let boundaryMatch = findNearestBoundary(state.matches, routeId);
		let workingFetchers = new Map(state.fetchers);
		deleteFetcher(workingFetchers, key);
		updateState({
			errors: { [boundaryMatch.route.id]: error },
			fetchers: workingFetchers
		}, { flushSync: (opts &amp;amp;&amp;amp; opts.flushSync) === true });
	}
```

For this explicit load, `routeId` is the route that owns the fetcher, not the route being loaded. In `findNearestBoundary` React Router searches from that route itself up to the root and takes the first route with an `ErrorBoundary`, or the root if none has one. We receive no data where we called `load()`: React Router deletes the fetcher and puts the error on that boundary. This happens when a request rejects or when we throw from the loader. When React Router later revalidates that fetcher automatically, it searches the page&apos;s matches from the loaded route&apos;s id instead; for a resource route outside those matches, `findIndex` returns `-1`, the search covers no routes and it falls back to the root, where we lose the fetcher too. We read that branch from the router source; in our reproduction below we exercise explicit loads.

When we return an error body from the loader, even with a 503 status, we receive it in `fetcher.data`, which is how we get the server&apos;s own &quot;could not read&quot; body into the bell. With single fetch, React Router keeps the HTTP status apart from the loader result. When a response carries the framework&apos;s own marker, React Router decodes it even with a 503 status: it returns a result under `data` and throws one under `error`. It throws a status of 400 or more without that marker, such as an error page from the edge, as an error response.

One more consequence surprised us. In our reproduction, when we awaited the router&apos;s fetch, we received a resolved promise even when the error went to the boundary. When we await `load()` inside a `try/catch`, we catch a rejected call, but not an error that has already been put on a route&apos;s boundary. When we wrap the call in error reporting, we do not make a fetcher safe.

![Three columns showing the same failed explicit background load. With an unwrapped fetcher, we reach the nearest error boundary, searched from the route that owns the fetcher upwards, and we lose the fetcher; in our panel incident, we saw the error screen in place of the page. With a clientLoader wrapper, we retry once and then return a declared unavailable body for the rejections we intend to contain, and the page stays. With a plain fetch, we keep the failure with the caller and nothing reaches the router.](/images/blog/updating-one-number-without-reloading-every-route/failed-load.svg#wide,phone,fit &quot;Where a failed explicit background load ends up in React Router 8.4.0, by how we make the request. From the router source and our reproduction, 15 October 2026.&quot;)

Source: TEEI, drawn from the React Router 8.4.0 source and our platform code, read on 15 October 2026.

## How we return failure as data

In the fix we gave the background routes a [`clientLoader`](https://reactrouter.com/how-to/client-data). In a `clientLoader`, we call `serverLoader()` from the browser and catch the rejections we intend to contain before we reach an error boundary. In each of those routes we call one function; here is the bell&apos;s:

*Adapted for publication.*

```ts
export async function loadBackgroundPanel({
  serverLoader,
  signal,
  tag,
  unreachable,
  retryDelayMs = BACKGROUND_PANEL_RETRY_DELAY_MS,
}: BackgroundPanelLoadOptions): Promise {
  for (let attempt = 1; ; attempt += 1) {
    try {
      return await serverLoader();
    } catch (error) {
      if (error instanceof Response || signal.aborted) throw error;
      if (attempt === 1) {
        await pause(retryDelayMs, signal);
        if (signal.aborted) throw error;
        continue;
      }
      if (isRouteErrorResponse(error)) {
        // A router error response (for example a 5xx from the edge): we keep the error
        // level we do not give a transport blip.
        reportClientError(tag, new Error(`response_${error.status}`), { status: error.status, attempts: attempt });
      } else {
        reportClientWarning(tag, error, { attempts: attempt });
      }
      return unreachable();
    }
  }
}
```

We made three decisions in it.

**What we retry.** We retry rejected loads, not every unsuccessful HTTP status: when `serverLoader()` resolves with the server&apos;s declared unavailable body, we return it unchanged. We keep `return await serverLoader()` inside the `try`, because if we returned its promise without awaiting it, we would not catch a later rejection here. In React Router 8.4.0, each time we call `serverLoader()` from a fetcher load, we go through single-fetch decoding to a new `fetch`, so with our second attempt we send another request. We retry once, after a pause that follows the abort signal, because a phone coming back from the background often has no network for a moment. The default pause is 1.5 seconds; that is a choice, not a measured value.

**What we pass through.** We pass on a rejection that is a `Response` instance in the browser, which is the router&apos;s own control flow, such as a redirect from `serverLoader()`. A response we throw on the server can instead arrive in the browser as a decoded router error object; we retry that rejection like any other. We also pass an aborted load through unchanged: we check the signal rather than infer why the request was cancelled, and we do not turn a cancellation into an unavailable panel.

**What we return.** After the second failure we report once and return the route&apos;s own declared &quot;unavailable&quot; body, which we receive in `fetcher.data`. We report a router error response, such as a 5xx from the edge, as an error, and any other rejection, such as a dropped connection, as a warning. Returning failure as data works only because we give the bell a contract for that data. We read its response as `pending` before a response, `ready` after a valid success, `unavailable` after a declared failure and `unreadable` for a body outside the contract; we show both failure readings as &quot;could not load&quot;, and we report the unreadable one. We do not infer an empty inbox from missing fields, which is how a failed read once looked like an empty inbox.

In the route itself we only connect the pieces:

*Adapted for publication.*

```ts
export async function clientLoader({ request, serverLoader }: Route.ClientLoaderArgs) {
  return loadBackgroundPanel({
    serverLoader,
    signal: request.signal,
    tag: &apos;bell.panel_unreachable&apos;,
    unreachable: bellPanelUnreachable,
  });
}
```

We write the wrapper per route, and we protect only the routes we give it to. It is not a property of fetchers.

## Choosing between a fetcher and a plain fetch

In our conventions we give a background request one of three homes:

1. **A plain `fetch()`, marked with a reason in the code**, when a transport failure must stay with the caller: background polls, fire-and-forget pings, and writes the person can retry on the same screen. With this choice we give up the router&apos;s cancellation, request sharing and revalidation.
2. **A fetcher with a `clientLoader` wrapper**, so that we turn a transport failure into a declared body in `fetcher.data` instead of a throw.
3. **A plain fetcher where we accept the takeover**, because an error screen is an acceptable outcome there. In our conventions we put it this way: &quot;a fetcher is right only where that takeover is acceptable&quot;.

Writes have their own broad refresh. After a successful action, React Router revalidates every matched loader by default. In the shell we decline through `shouldRevalidate` only for a named write that provably touches none of the shell&apos;s data, and we end the function in `defaultShouldRevalidate`. We avoid rules shaped around the URL: with a fetcher write we can change a parent route&apos;s list while the URL stays the same, so a rule that returns `false` for an unchanged URL would skip exactly the refresh that write needs.

With the narrow refresh we decide what we load; with the wrapper we decide what a failure costs.

## Verify this

We test with a real router to check where a rejected fetcher load ends up. In our own suite, without the wrapper, we see the rejection on the root boundary and receive no fetcher data; with the wrapper, `router.state.errors` stays null and we receive the declared unavailable body in `fetcher.data`. We check that transition in the router&apos;s state, without rendering the page. In the wrapper&apos;s unit tests we also cover recovery after one rejection, a thrown `Response` passed on without a retry, an aborted load passed on without a report, and an abort during the pause. We would catch an upgrade that stopped routing this rejected explicit load to the root boundary.

That suite is internal, but you can reproduce where a fetcher error lands in your own project, with nothing but `react-router` installed: no server, no network, no rendering. Save this as `fetcher-error-repro.mjs` and run it with `node`.

*Adapted for publication.*

```js
import { createRequire } from &apos;node:module&apos;;
const require = createRequire(import.meta.url);
const { createMemoryRouter } = require(&apos;react-router&apos;);
const { version } = require(&apos;react-router/package.json&apos;);

const Boundary = () =&amp;gt; null;
const fail = () =&amp;gt; Promise.reject(new TypeError(&apos;Load failed&apos;));

async function run(label, routes, callerRouteId) {
  const router = createMemoryRouter(routes, { initialEntries: [&apos;/app/home&apos;] });
  for (let i = 0; i &amp;lt; 50 &amp;amp;&amp;amp; !router.state.initialized; i += 1) await new Promise((r) =&amp;gt; setTimeout(r, 0));
  let handed;
  const un = router.subscribe((s) =&amp;gt; { const f = s.fetchers.get(&apos;badge&apos;); if (f?.state === &apos;idle&apos;) handed = f.data; });
  await router.fetch(&apos;badge&apos;, callerRouteId, &apos;/api/count&apos;);
  un();
  console.log(label, JSON.stringify({
    errorsOn: router.state.errors ? Object.keys(router.state.errors) : null,
    message: router.state.errors ? Object.values(router.state.errors)[0]?.message : null,
    fetcherHandedData: handed ?? null,
  }));
  router.dispose();
}

console.log(&apos;react-router&apos;, version);
const tree = (countLoader, shellBoundary) =&amp;gt; [{
  id: &apos;root&apos;, path: &apos;/&apos;, ErrorBoundary: Boundary, loader: () =&amp;gt; null,
  children: [{
    id: &apos;shell&apos;, path: &apos;app&apos;, loader: () =&amp;gt; null, ...(shellBoundary ? { ErrorBoundary: Boundary } : {}),
    children: [{ id: &apos;home&apos;, path: &apos;home&apos;, loader: () =&amp;gt; ({ page: &apos;home&apos; }) }],
  }, { id: &apos;count&apos;, path: &apos;api/count&apos;, loader: countLoader }],
}];

await run(&apos;1 throws, no boundary below the root:&apos;, tree(fail, false), &apos;home&apos;);
await run(&apos;2 throws, the shell has a boundary (home inherits it):&apos;, tree(fail, true), &apos;home&apos;);
// 3: the same failure; we catch it in a wrapper and return a declared body
const safe = async () =&amp;gt; { try { return await fail(); } catch { return { ok: false, code: &apos;unavailable&apos; }; } };
await run(&apos;3 a wrapper returns a declared body:&apos;, tree(safe, true), &apos;home&apos;);
// 4: we return an error-shaped object from the loader instead of throwing
await run(&apos;4 an error body returned, not thrown:&apos;, tree(() =&amp;gt; ({ ok: false, status: 503 }), true), &apos;home&apos;);
```

When we ran the original of this script against React Router 8.4.0 on 15 October 2026, we got this output (labels translated):

*Adapted for publication.*

```
react-router 8.4.0
1 throws, no boundary below the root: {&quot;errorsOn&quot;:[&quot;root&quot;],&quot;message&quot;:&quot;Load failed&quot;,&quot;fetcherHandedData&quot;:null}
2 throws, the shell has a boundary (home inherits it): {&quot;errorsOn&quot;:[&quot;shell&quot;],&quot;message&quot;:&quot;Load failed&quot;,&quot;fetcherHandedData&quot;:null}
3 a wrapper returns a declared body: {&quot;errorsOn&quot;:null,&quot;message&quot;:null,&quot;fetcherHandedData&quot;:{&quot;ok&quot;:false,&quot;code&quot;:&quot;unavailable&quot;}}
4 an error body returned, not thrown: {&quot;errorsOn&quot;:null,&quot;message&quot;:null,&quot;fetcherHandedData&quot;:{&quot;ok&quot;:false,&quot;status&quot;:503}}
```

We capture the fetcher&apos;s data in a subscriber, because idle entries are removed from `router.state.fetchers` right after subscribers are notified; with `useFetcher`, we read the copy React keeps. In lines 1 and 2 we see the error on the nearest boundary and nothing handed to the fetcher; in line 3 we keep the page with a declared body. Know the limits of the script: in case 3 we use a plain `try/catch` in the loader rather than a `clientLoader`, in case 4 we return an ordinary object with a `status` field rather than an HTTP 503 response, and we inspect the router&apos;s state without rendering anything. In cases 1 and 2 we also see a resolved promise from `await router.fetch(...)`, not a rejection.

If you are building with React Router or Cloudflare, or with TEEI, [talk to us](/partners/technology/) and we will walk you through the code.

## Appendix: versions and settings

- React Router 8.4.0 (`react-router`, `@react-router/dev` and `@react-router/cloudflare`), on Cloudflare Workers. We read the code and settings described here on 15 October 2026; earlier changes carry the date of their commit.
- We hold the live socket in the `LivePresence` Durable Object, bound as `LIVE_PRESENCE`.
- [TEEI Language](https://language.theeducationalequalityinstitute.org/) and [TEEI Mentorship](https://mentorship.theeducationalequalityinstitute.org/) share one React Router codebase built into two Workers, which [Two Workers, One React Router 8 App: Decide at the Boundary](/blog/one-codebase-two-programmes-react-router-8-cloudflare-workers-en/) describes. We describe the Durable Object that sends the ping in [Durable Objects for Rings, SQL for Outcomes](/blog/real-time-availability-with-durable-objects-en/), and how we keep open tabs working through a deploy in [Old Tabs, New Workers: A Guarded Reload](/blog/keeping-open-browser-tabs-working-across-worker-deploys-en/).

Further reading in the official documentation: [`useFetcher`](https://reactrouter.com/api/hooks/useFetcher), [fetchers](https://reactrouter.com/how-to/fetchers), [revalidation](https://reactrouter.com/how-to/optimize-revalidation), [`shouldRevalidate`](https://reactrouter.com/start/framework/route-module#shouldrevalidate), [error boundaries](https://reactrouter.com/how-to/error-boundary) and [error reporting](https://reactrouter.com/how-to/error-reporting).</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>React Router</category><category>Cloudflare Workers</category><category>Revalidation</category><category>Error Handling</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>Why We Still Handle WebSockets Before Astro</title><link>https://theeducationalequalityinstitute.org/blog/websockets-through-astro-at-the-worker-boundary-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/websockets-through-astro-at-the-worker-boundary-en/</guid><description>We keep WebSocket upgrades outside Astro as a precaution. Locally, a copied response kept its socket; a rebuild without the socket broke the upgrade.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>A WebSocket upgrade on Cloudflare Workers carries something an ordinary HTTP response does not: the socket itself. Return status 101 and the headers while omitting that property, and the upgrade fails. Copying the whole response, however, preserved the socket in our local test. That distinction changed the explanation for a boundary we had already built.

Our Astro website answers the homepage feed&apos;s upgrade in a custom Worker entry, before calling Astro. We introduced that boundary on 14 October 2026 as a precaution, expecting response copying to break the connection. A five-route workerd experiment the next day showed exactly which copies worked and which failed. We kept the boundary because it gives the upgrade a short, explicit return path, while ordinary requests still use Astro.

This article covers the response contract, the experiment and the generated module that Wrangler deploys. The [Astro on Workers homepage case](/blog/our-homepage-is-a-live-system-astro-cloudflare-workers-en/) follows the handoff from a rendered activity fragment to the browser&apos;s updates; the [Cloudflare overview](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/) maps the wider platform. The implementation and production probes described below are dated 15 October 2026. The probes establish a handshake and message exchange for that deployment, without establishing delivery of completed sessions.

**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.

## A 101 response must carry the socket

The browser starts with a `GET` carrying `Upgrade: websocket`. In Workers, the successful response has status 101, a `null` body and a `webSocket` property holding the client end of a [`WebSocketPair`](https://developers.cloudflare.com/workers/runtime-apis/websockets/). The runtime uses that property to connect the client when the response leaves the Worker. Status and headers alone cannot carry it.

An Astro endpoint can return the upgrade, sending the response back through Astro and the adapter. A custom Worker entry can instead return it before Astro runs. The [Cloudflare adapter guide](https://docs.astro.build/en/guides/integrations-guide/cloudflare/#changed-custom-entrypoint-api) supports that entry through Wrangler&apos;s `main`, using a standard Worker object with custom exports such as Durable Object classes.

Our original reason for choosing the entry was a claim that `new Response(response.body, response)` would lose the socket. Cloudflare itself uses that construction [to make response headers mutable](https://developers.cloudflare.com/workers/examples/cache-using-fetch/). Types exposed the `webSocket` property but did not establish what the constructor copied, so we tested the runtime.

## Five ways to return the same upgrade

On 15 October 2026, we built a small Worker and Durable Object. The object accepted a socket through the Hibernation API, sent `hello` and returned `new Response(null, { status: 101, webSocket: client })`. The Worker obtained that response as `res` and returned it through five branches:

*Adapted for publication.*

```js
if (pathname === &apos;/orig&apos;) return res;
if (pathname === &apos;/copy&apos;) return new Response(res.body, res);
if (pathname === &apos;/copy-set-header&apos;) {
  const r = new Response(res.body, res);
  r.headers.set(&apos;Content-Language&apos;, &apos;en&apos;);
  return r;
}
if (pathname === &apos;/named&apos;) {
  return new Response(res.body, { status: res.status, headers: res.headers });
}
if (pathname === &apos;/named-with-ws&apos;) {
  return new Response(res.body, {
    status: res.status,
    headers: res.headers,
    webSocket: res.webSocket,
  });
}
```

We ran the experiment with Miniflare 4.20260701.0, workerd 1.20260701.1 and Node.js 22.23.2, once with compatibility date 2024-09-23 and once with 2026-04-07. For each branch, a client attempted to open, receive `hello`, send `ping`, receive `pong` and close.

Table: Local workerd results on 15 October 2026. Both compatibility dates produced the same outcomes.

| Branch | Construction | Result |
|---|---|---|
| `/orig` | Return the object&apos;s response | Opened, `hello`, `pong`, close 1000 |
| `/copy` | Use `res` as the constructor&apos;s init | The same successful exchange |
| `/copy-set-header` | Copy, then set `Content-Language` | The same successful exchange |
| `/named` | Pass only status and headers | Constructor threw `RangeError`; upgrade failed |
| `/named-with-ws` | Add `webSocket` to the named fields | The same successful exchange |

The failing constructor reported that response statuses must be between 200 and 599, inclusive. In this runtime, 101 is permitted with a socket. Adding `webSocket: res.webSocket` to the same named fields turned the failed construction into a working upgrade.

Our recorded explanation was wrong about the whole-response copy: it preserved the socket, even after a header change. The experiment isolates omission of `webSocket` as the difference between the two named-field branches. It does not establish that every response transformation is safe.

We also read the response-handling code in Astro 7.2.10 and adapter 14.2.4. The adapter uses the whole-response copy in a header-setting fallback; Astro&apos;s i18n and error handling use named fields on some paths. Replacing a 101&apos;s status with 404 would end the upgrade; retaining 101 without its socket would fail construction as `/named` did. We have not established that those branches receive an upgrade response, or sent a 101 through a complete Astro build. The five-route result belongs to local workerd, rather than the production runtime.

## Keep the upgrade&apos;s return path explicit

We still answer before Astro. The response then depends on the Durable Object and the Worker layers we control, without depending on how a renderer or adapter version rebuilds it. That is a maintenance choice supported by the [documented Worker-to-Durable-Object flow](https://developers.cloudflare.com/durable-objects/best-practices/websockets/), rather than a demonstrated requirement that Astro cannot carry a socket.

*Adapted for publication, with a stand-in path, import and object name.*

```ts
import { handle } from &apos;@astrojs/cloudflare/handler&apos;;

export { LiveLedger } from &apos;./live-ledger&apos;;

const LIVE_SOCKET_PATH = &apos;/socket&apos;;
const LIVE_LEDGER_NAME = &apos;feed&apos;;

export default {
  async fetch(request: Request, env: Cloudflare.Env, ctx: ExecutionContext): Promise {
    const { pathname } = new URL(request.url);
    const isUpgrade = request.headers.get(&apos;Upgrade&apos;)?.toLowerCase() === &apos;websocket&apos;;
    if (pathname === LIVE_SOCKET_PATH &amp;amp;&amp;amp; isUpgrade) {
      const ledger = env.LIVE_LEDGER;
      if (!ledger) {
        return new Response(&apos;Live ledger is not configured&apos;, {
          status: 503,
          headers: { &apos;Cache-Control&apos;: &apos;no-store&apos; },
        });
      }
      const stub = ledger.get(ledger.idFromName(LIVE_LEDGER_NAME));
      return stub.fetch(new Request(&apos;https://durable-object/ws&apos;, request));
    }
    return handle(request, env, ctx);
  },
};
```

Both conditions must match: the socket path and the Upgrade header. The same header on another path goes to Astro. `new Request(url, request)` preserves the original handshake headers, including `Sec-WebSocket-Key` and `Sec-WebSocket-Version`; the URL shown is a stand-in for routing within the object.

We return `stub.fetch()` directly. A missing binding produces 503 instead of falling through. A plain request to the socket path goes to an Astro endpoint that answers `426 Upgrade Required` with an explanation. This keeps an ordinary HTTP visit useful without confusing it with the upgrade.

![Sequence diagram with two requests to the same path. In the first, a GET with an Upgrade header passes Cloudflare and the build-generated wrapper, our Worker entry sends it to the Durable Object, which accepts the socket, sends a hello and returns a 101 with the client socket; the 101 goes back through the entry and the wrapper unchanged, and Astro is never called. In the second, the same GET without the Upgrade header goes from our entry to Astro, whose route answers 426 with an explanation.](/images/blog/websockets-through-astro-at-the-worker-boundary/two-requests.svg#wide,phone,fit &quot;Two requests to the socket path. With the Upgrade header, the 101 travels from the Durable Object to the browser without entering Astro. Without it, Astro answers 426. Drawn from the website’s code, 15 October 2026.&quot;)

Source: TEEI, drawn from the website&apos;s Worker entry, its build step and the Durable Object, 15 October 2026.

## Preserve the class export through the build

The source entry is not necessarily the deployed entry. After the adapter bundles ours, a postbuild step moves that module aside and writes a wrapper that adds a scheduled handler, redirects and video handling. On the way back, its response repair only handles redirects missing `Location`:

*Adapted for publication.*

```js
function repairFallbackRedirect(res, url) {
  if (res.status &amp;lt; 300 || res.status &amp;gt; 399 || res.headers.has(&apos;Location&apos;)) return res;
  // Repair the redirect that reached this branch.
  // …
}
```

A 101 returns unchanged at the first line. That check matters because answering before Astro still leaves our own wrapper in the path.

The wrapper must also re-export the Durable Object class. Wrangler binds the class by name in the module deployed as `main`; importing another module&apos;s default Worker object does not export its named classes. Each replacement entry has to preserve both contracts: the handlers and the class export.

Our build step looks for the class name in the adapter module and for the re-export line in the wrapper it generates. Those are text checks. The recorded fixture test goes further: it imports the generated module and checks that the class, `fetch` and `scheduled` exports are functions. Given an adapter module without the class, the step must fail and leave that module unwrapped.

That test establishes an export contract for a fixture build. The local workerd experiment establishes the response-copy behaviour. The production probe establishes that the observed deployment could upgrade. Each answers a different question; a correct export does not prove a working socket.

Builds with our error tracker&apos;s wrapper add another outer module. The diagram and code discussion describe the build without that optional layer; another wrapper must preserve the same exports and response contract.

## Keep connection state and retained history separate

The object creates the socket pair and accepts its server side with the Hibernation API:

*Adapted for publication; capacity handling omitted.*

```ts
private openSocket(request: Request): Response {
  if (request.headers.get(&apos;Upgrade&apos;)?.toLowerCase() !== &apos;websocket&apos;) {
    return new Response(&apos;Expected a WebSocket upgrade&apos;, {
      status: 426,
      headers: { Upgrade: &apos;websocket&apos; },
    });
  }
  const pair = new WebSocketPair();
  const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
  this.ctx.acceptWebSocket(server, [SOCKET_TAG]);
  const hello = {
    type: &apos;hello&apos;,
    ts: new Date().toISOString(),
    latest: this.latest(RECENT_LIMIT).map(toPublicView),
  };
  try {
    server.send(JSON.stringify(hello));
  } catch {
    // The client may have gone away.
  }
  return new Response(null, { status: 101, webSocket: client });
}
```

[`acceptWebSocket()`](https://developers.cloudflare.com/durable-objects/best-practices/websockets/) lets the object hibernate between messages while connections remain open. With `server.accept()`, open sockets would keep it in memory. For a broadcast we ask the runtime for `getWebSockets(&apos;live&apos;)`, serialise the message once and catch failures per send; we do not depend on a JavaScript connection array surviving hibernation.

History lives in the object&apos;s SQLite storage. The constructor creates its table and index with `IF NOT EXISTS` inside `blockConcurrencyWhile()`. Before returning an upgrade, the object sends a `hello` containing a public view of retained events, using the projection described in [the live-feed article](/blog/public-live-activity-feed-without-participant-identities-en/).

The object retains up to 200 events and includes up to 50 in `hello`. Pruning happens before broadcasting an event, so a later connection receives retained state, rather than a replay of everything an earlier connection saw. Reconnecting sends another snapshot. Receiving, applying and displaying that snapshot are client responsibilities; our probes below do not establish browser restoration.

Connections can end during runtime updates even when hibernated. Cloudflare&apos;s [12 October 2026 change](https://developers.cloudflare.com/changelog/post/2026-10-12-longer-runtime-update-grace-period/) increased the grace period for requests and connections to up to 20 minutes, while still allowing earlier disconnection. Reconnection needs a state-restoration policy, not just another successful handshake. [Keeping open browser tabs working across Worker deploys](/blog/keeping-open-browser-tabs-working-across-worker-deploys-en/) examines that client-side problem.

## What the production probes establish

On 15 October 2026, three curl probes against the public website distinguished the routing paths:

| Request | Observed response |
|---|---|
| Socket path with Upgrade | `101 Switching Protocols`, followed by a `hello` frame |
| Same path without Upgrade | 426, an explanation and `Cache-Control: no-store` |
| Public JSON snapshot path with the same Upgrade header | 200 JSON from Astro |

Three separate Node.js client runs that day opened, received `hello` and `pong`, and closed with 1000. Every recorded `hello` had an empty `latest` list. These runs establish that the observed path could negotiate a socket and exchange messages. They do not establish completed-session delivery, a latency distribution, concurrent capacity, hibernation savings or what clients experience during a deployment.

## Decide whether to put the socket before Astro

The custom entry fits when you want a protocol-specific response path alongside Astro&apos;s ordinary routes. Its cost is a second place where request handling must be understood and tested.

If a socket needs a signed-in session, an Origin check or values normally computed in Astro middleware, arrange for that work to run before accepting the upgrade. Shared functions can serve both paths; keeping the socket outside Astro does not require duplicating their implementation.

Choose the Durable Object&apos;s scope separately. A tag groups sockets inside one object; it does not distribute connections across objects. A single named object keeps history and fan-out together. Rooms or regions can divide that work, but a feed spanning them then needs an explicit event-distribution design. We have not load-tested this stream&apos;s capacity.

You can upgrade in a plain Worker without a Durable Object. We use the object because the feed needs retained history and a set of connected clients together, with [hibernation and persistent state](https://developers.cloudflare.com/workers/best-practices/workers-best-practices/). Choosing the upgrade boundary alone does not decide where those responsibilities belong.

## Reproduce the contract in your stack

Start with the five response branches above in a local Worker and Durable Object, using `wrangler dev` or the [Workers Vitest integration](https://developers.cloudflare.com/workers/testing/vitest-integration/). For each working branch, assert the complete exchange: open, `hello`, `ping`/`pong`, then close 1000. For `/named`, assert a constructor error and failed upgrade. A status-only assertion misses the message exchange the connection is intended to support.

Then add your own response-handling layers one at a time. Record whether each rebuilding branch actually receives a 101, separately from whether the connection still works afterwards. A branch that would discard a socket is not an observed failure until the upgrade reaches it. Finally, check the generated module&apos;s exports as well as the socket through the entry you intend to deploy.

This reproduction extends the local experiment to your own stack. It does not turn the dated production probes into delivery or load evidence.

## Configuration examined

The following excerpt is adapted from the website configuration read on 15 October 2026. Choose an actual entry path for your project; the placeholder is explanatory.

*Adapted for publication.*

```toml
main = &quot;&quot;
compatibility_date = &quot;2024-09-23&quot;

[[durable_objects.bindings]]
name = &quot;LIVE_LEDGER&quot;
class_name = &quot;LiveLedger&quot;

[[migrations]]
tag = &quot;live-ledger-v1&quot;
new_sqlite_classes = [&quot;LiveLedger&quot;]
```

**Heartbeat and close.** Our `setWebSocketAutoResponse` registration pairs the text messages `ping` and `pong`. The runtime can answer them without waking a hibernating object; these are application messages, distinct from WebSocket Ping and Pong control frames. A reply alone does not establish that the object was hibernating.

With a compatibility date before 2026-04-07 and without [`web_socket_auto_reply_to_close`](https://developers.cloudflare.com/workers/configuration/compatibility-flags/), the runtime does not complete the close handshake automatically. Our `webSocketClose` closes the object&apos;s side, converting reported code 1005 to 1000 because [RFC 6455](https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1) reserves 1005 for reporting absence of a status and forbids sending it in a Close frame. We close an errored socket with 1011.

**Versions read locally on 15 October 2026:** Astro 7.2.10, `@astrojs/cloudflare` 14.2.4, Wrangler 4.107.0, workerd 1.20260701.1 and `@cloudflare/workers-types` 4.20260702.1. Those installed versions are the source-reading context, not versions read from the deployed Worker. The copy experiment&apos;s Miniflare and Node.js versions are recorded with its results above.

## Continue in the Astro series

Return to the [Astro on Workers hub](/blog/our-homepage-is-a-live-system-astro-cloudflare-workers-en/) for the complete reading path. To see why a request can reach the Worker without reaching Astro middleware, continue with [Worker-first and assets-first routing](/blog/worker-first-or-assets-first-routing-astro-on-cloudflare-en/).</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Astro</category><category>Cloudflare Workers</category><category>Durable Objects</category><category>WebSockets</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>Why We Broke a React Router Build With an Unused Export</title><link>https://theeducationalequalityinstitute.org/blog/what-belongs-in-a-react-router-client-bundle-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/what-belongs-in-a-react-router-client-bundle-en/</guid><description>We can fail a React Router build on code we would never send to the browser: we check imports before we drop unused code. How we split our files.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>We made a React Router route build by deleting one word: `export`. In our reproduction, we called a helper only from the loader, yet still reached its server-only import during the client build. We remove four server exports first, resolve and check the remaining imports by file name next, and discard unused code afterwards. We can therefore fail the build on code we would never send to the browser. Across the thirteen boundary fixes we recorded, we moved shared values into plain modules, isolated server helpers or removed exports nothing in the browser needed. If your type checks and unit tests pass but your build fails, follow the imports your remaining exports reach.

TEEI Language and TEEI Mentorship are two products built from one [React Router 8](https://reactrouter.com/) codebase and deployed as two Cloudflare Workers; [Two Workers, One React Router 8 App: Decide at the Boundary](/blog/one-codebase-two-programmes-react-router-8-cloudflare-workers-en/) describes the application and [How TEEI Runs Its Platforms on Cloudflare Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/) the platform underneath it. Between 21 July and 14 October 2026, we recorded thirteen fixes for the same error, `Server-only module referenced by client`. We found these failures through production builds and, in one case, through requests to our development server. Our type checks, lint and unit tests did not detect the boundary error itself. Below we explain why, and how we decide what belongs in the React Router client bundle.

**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 the framework removes, and what it ships

We compile each route file twice, once for the Worker and once for the browser. The documentation on [code splitting](https://reactrouter.com/explanation/code-splitting) says that &quot;any server-only Route Module APIs will be removed from the bundles&quot;. We found the exact list in the installed plugin, `@react-router/dev` 8.4.0, where a constant names four exports: `loader`, `action`, `middleware` and `headers`. In the browser build, we then take three steps in a fixed order:

1. **We remove the four server exports** from the route file, together with the code only they use. Before resolving browser imports, we remove bindings used only by the server exports and any import declarations left empty. A bare side-effect import has no binding, so it survives this step.
2. **We resolve every import that remains and check it by file name.** At this point every remaining export of the route file still counts, custom ones included. For a `.server` file, we get a failed build here.
3. **We discard unused code.** From each route&apos;s browser entry, we have re-exported only the exports React Router knows, such as the component, `meta`, `links`, `handle` and `ErrorBoundary`. A comment in the plugin says why: without that &quot;we can&apos;t tree-shake any unused custom exports&quot;. That entry makes an unused custom export eligible for removal, but we remove it only now, after we have resolved and checked its imports.

That leaves three cases for an import in a route file:

- **Used only by the four server exports, or by code only they call.** We never resolve it in the browser build, so it is not checked and does not ship.
- **Used by the component, `meta`, `links` or another known client export.** We resolve it and include it in the client build. If it is a `.server` file, our build fails.
- **Used only by a custom export that nothing in the browser uses, such as a helper you export so a test can call it.** We still resolve it. If it is a `.server` file, we get a failed build before anything is dropped. If it is a plain module, its unused values can be dropped, as in our reproduction, where the module had no side effects; a module&apos;s side effects can remain in the browser output.

The third case caught us more than once. In our reproduction on 15 October 2026, a route whose loader called an exported helper that read a server-only module failed the build. The same route with the word `export` deleted from the helper built cleanly. And when the exported helper read a plain module instead, the build passed, and neither the helper nor the plain module&apos;s string was in the browser output.

![Diagram of one React Router route file in the browser build. The loader, action, middleware and headers exports sit in a dashed box marked as removed from the browser build. The loader’s import of a server-only module stays on the server. Imports of the same server-only module from the component and from an exported helper are marked as a build failure. The exported helper’s import of a plain module without side effects is resolved, then dropped in our reproduction. The component imports a shared file that is safe for both builds, and the server-only module imports the shared value on the server. A strip below shows the order: remove the four exports, resolve every remaining import and check .server names, then tree-shake.](/images/blog/what-belongs-in-a-react-router-client-bundle/route-file.svg#wide,phone,fit &quot;What we get from React Router 8 with one route file. We remove four exports first, then resolve every remaining import and check it by name; only after that do we drop unused code.&quot;)

Source: TEEI, behaviour read in @react-router/dev 8.4.0 and reproduced on 15 October 2026.

## Where we reject a client import

The documentation on [`.server` modules](https://reactrouter.com/api/framework-conventions/server-modules) says: &quot;The build will fail if any code in a `.server` file or `.server` directory accidentally ends up in the client module graph.&quot; In the 8.4.0 plugin we read, the check is a Vite plugin with `enforce: &apos;pre&apos;`, and we use it to check every client import, including dynamic imports. We get a build error when the resolved file name ends in `.server` (with any JavaScript or TypeScript extension) or the file sits in a `.server` directory.

This is the error our reproduction printed on 15 October 2026, for a route component that read a constant from a server-only file. We replaced the route file&apos;s path with a placeholder; the rest is as printed:

*Adapted for publication.*

```text
Error: Server-only module referenced by client

    &apos;../lib/limits.server&apos; imported by route &apos;&apos;

  React Router automatically removes server-code from these exports:
    `loader`, `action`, `middleware`, `headers`

  But other route exports in &apos;&apos; depend on &apos;../lib/limits.server&apos;.

  See https://reactrouter.com/explanation/code-splitting#removal-of-server-code
```

When the importer is not a route file, the message is shorter: the first line and which file imported the module. We need to account for three properties of the check:

- **We are checked in the client build only.** We never get a `.server` error from our Worker build, which is correct.
- **In development we meet the check when a client module is transformed.** Vite&apos;s dependency scan does not run it, but a page request can reach it before the browser asks for the module. On 30 July 2026, on our development server, we got a 500 instead of the 403 we expected when a session without the staff role requested a page our staff use. We reproduced it in three independent runs. The page&apos;s reply box read its length limit from a server-only module; with the constant moved, we got 403 for the same request and no trace of the error in the server log.
- **We do not build the client module graph in type checks, lint or unit tests.** In the words of the commit that fixed our 15 August failure: &quot;React Router&apos;s client/server boundary is per file, not per export&quot;, and &quot;typecheck, eslint and vitest cannot see this class of bug&quot;.

On 15 August, a link builder used by a route component shared a module with ranking code that had a server-only import. The component never called the ranking code. We still had to resolve the module&apos;s imports before we could discard its unused exports, and we remove the four server exports only from route modules, so importing one name from an ordinary module gets no earlier removal. We reproduced this with a three-line module.

### What we still have to keep out of the client

We are checked on file names, not on what a module contains. A secret in an ordinary module that a component uses ships to the browser, and we still get a passing build. In our reproduction, a string in a plain module rendered by a component was in the client output, and `react-router build` exited with 0. We therefore keep anything that must never reach a browser, such as a database client, a key or a query, in a `.server` file, so that we get a build failure when an import of it accidentally remains in the client module graph.

## Thirteen fixes, three shapes

We found our thirteen fixes by searching commit messages and code comments; there may be more that used other words. In each case, a value the client used or an extra route export left code in the client graph that reached a `.server` module, directly or through another file. We kept the check every time and changed the code in one of three ways.

**We moved a shared value into a plain file.** On 21 September 2026 a form that lists five session formats read the five strings and a type guard from a server module. We moved them into a plain file that both sides import. Our main line did not build for several hours that day, and one of us found it while running a release check for unrelated work. We treat a change at this boundary as verified only once `react-router build` has passed on it; each release build of a Worker runs it first.

**We moved server helpers into a server-only module of their own.** On 24 September 2026 a route exported its batching helpers beside its `action`. We moved the helpers into a server-only module, and after the fix the route exported only `action`.

**We removed an export.** On 14 October 2026 an API route with no component exported its count function, and that function reached a server-only module. We still compile such a route as a route module for the browser, so the check applied. We removed the export, as we had for two impact endpoints that exported their compute functions on 2 October. On 28 September a statistics route exported, for a test, a query whose text was built with server-only helpers; there we moved the query into a server-only module instead.

## The split that fixes it

When a value belongs on both sides, we put it in a plain module that both can import. A compact example comes from 3 August 2026. A staff page rendered a ratio only when enough data stood behind it. The function that computed the ratio and the minimum sample size lived in the same server module as the queries that produced the numbers. They were pure arithmetic over two integers, with no database and no environment, and the component was right to need them. The commit put it this way: &quot;a value a rendered component needs is not server code, however closely it sits to some.&quot;

*Adapted for publication.*

```ts
// metrics.ts: no server imports, safe for both builds
export interface Ratio { numerator: number; denominator: number }
export const MIN_SAMPLE_FOR_RATE = 5;
export function rate(r: Ratio, minSample = MIN_SAMPLE_FOR_RATE): number | null {
  if (r.denominator &amp;lt; minSample || r.denominator === 0) return null;
  return r.numerator / r.denominator;
}
```

*Adapted for publication.*

```ts
// metrics.server.ts: the queries stay here
import { createContext } from &apos;react-router&apos;;
import { MIN_SAMPLE_FOR_RATE, type Ratio } from &apos;./metrics&apos;; // needed because this module uses the names itself
export { MIN_SAMPLE_FOR_RATE, rate, type Ratio } from &apos;./metrics&apos;; // so existing server imports keep working

export interface Database {
  countCompleted(): Promise;
}
export const databaseContext = createContext();

export async function measureCompletion(db: Database): Promise {
  const r = await db.countCompleted();
  return r.denominator &amp;lt; MIN_SAMPLE_FOR_RATE ? null : r;
}
```

*Adapted for publication.*

```tsx
// routes/staff.metrics.tsx
import { databaseContext, measureCompletion } from &apos;~/lib/metrics.server&apos;; // used by the loader only: removed from the client build
import { rate } from &apos;~/lib/metrics&apos;; // used by the component; we include it in the client build

export async function loader({ context }: Route.LoaderArgs) {
  const database = context.get(databaseContext);
  return { completion: await measureCompletion(database) };
}

export default function Metrics({ loaderData }: Route.ComponentProps) {
  return ;
}
```

In this shortened example, the database exposes one query, `countCompleted()`, and `Measure` stands for any component that displays the value. Before calling React Router, the Worker stores that database with `routerContext.set(databaseContext, database)`, using the same exported context key. The loader reads the database from the provider; the provider itself is not a database.

We keep the import and the re-export separate. With `export { x } from &apos;./metrics&apos;`, we re-export `x` without binding it inside the server module. On 21 September 2026 we got a `ReferenceError` in tests after using a re-export alone, and we fixed it by adding the import. The component still imports the plain module directly.

## When this is the wrong pattern

**We use the loader when the component needs server-owned data.** A count or a setting read for the current request comes through the loader; we keep its query and server helper on the server. We add a plain sibling when both builds need the same code, such as the ratio formula and its minimum sample size. That split costs another file, even when it holds only five strings and a type guard, as ours did on 21 September 2026.

**The order is the plugin&apos;s, at one version.** We found the four exports in the plugin code rather than the documentation, and read the order of the three steps in `@react-router/dev` 8.4.0. Check the version you have installed before you rely on it.

## Verify this

You can repeat the export experiment in your own project. We ran these steps on 15 October 2026 in a small hand-made project with React Router 8.4.0, `@react-router/dev` 8.4.0 and Vite 8.1.4, using React Router&apos;s default Node server build rather than the Cloudflare plugin. We ran them again on 21 October 2026 from the files below, with the same versions and the same results.

We start from these files rather than a generated template: `create-react-router` downloads its starter from the main branch of React Router&apos;s template repository, so a project made with it later may not match the one we tested.

*Example for your own project.*

```sh
mkdir -p r5-repro/app/routes r5-repro/app/lib &amp;amp;&amp;amp; cd r5-repro
cat &amp;gt; package.json &amp;lt;&amp;lt;&apos;EOF&apos;
{
  &quot;name&quot;: &quot;r5-repro&quot;,
  &quot;private&quot;: true,
  &quot;type&quot;: &quot;module&quot;,
  &quot;dependencies&quot;: {
    &quot;react&quot;: &quot;19.3.0&quot;,
    &quot;react-dom&quot;: &quot;19.3.0&quot;,
    &quot;react-router&quot;: &quot;8.4.0&quot;,
    &quot;@react-router/node&quot;: &quot;8.4.0&quot;,
    &quot;isbot&quot;: &quot;5.1.44&quot;
  },
  &quot;devDependencies&quot;: {
    &quot;@react-router/dev&quot;: &quot;8.4.0&quot;,
    &quot;vite&quot;: &quot;8.1.4&quot;
  }
}
EOF
cat &amp;gt; react-router.config.ts &amp;lt;&amp;lt;&apos;EOF&apos;
export default { ssr: true };
EOF
cat &amp;gt; vite.config.ts &amp;lt;&amp;lt;&apos;EOF&apos;
import { reactRouter } from &apos;@react-router/dev/vite&apos;;
import { defineConfig } from &apos;vite&apos;;
export default defineConfig({ plugins: [reactRouter()] });
EOF
cd app
cat &amp;gt; root.tsx &amp;lt;&amp;lt;&apos;EOF&apos;
import type { ReactNode } from &apos;react&apos;;
import { Links, Meta, Outlet, Scripts } from &apos;react-router&apos;;

export function Layout({ children }: { children: ReactNode }) {
  return (
    
      
      {children}
    
  );
}

export default function App() {
  return ;
}
EOF
cat &amp;gt; routes.ts &amp;lt;&amp;lt;&apos;EOF&apos;
import { index } from &apos;@react-router/dev/routes&apos;;
export default [index(&apos;routes/home.tsx&apos;)];
EOF
cat &amp;gt; routes/home.tsx &amp;lt;&amp;lt;&apos;EOF&apos;
export default function Home() { return &lt;p&gt;home&lt;/p&gt;; }
EOF
cd .. &amp;amp;&amp;amp; npm install
```

1. Create the project with the commands above. They pin `react-router`, `@react-router/dev` and `@react-router/node` to 8.4.0 and Vite to 8.1.4, the framework and bundler versions we used.
2. In `app/lib`, create `limits.server.ts` with `export const REPLY_MAX_LENGTH = 2000;`. In the home route, `home.tsx` in `app/routes`, import it at the top and use it in the component: `</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>React Router</category><category>Vite</category><category>Cloudflare Workers</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>Cloudflare Workers: Shorter Trips, Fewer Trips</title><link>https://theeducationalequalityinstitute.org/blog/why-our-workers-run-near-the-database-not-the-user-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/why-our-workers-run-near-the-database-not-the-user-en/</guid><description>We run Workers near our database to shorten trips, and batch reads and rewrite loaders to remove them: 84 trips to six on a development server.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>We heard from users in several countries that our pages were slow. On a development server on 21 September 2026, we counted 84 database round trips for the learner dashboard. We cut that to 26 by batching reads, then to six by rewriting loaders. We also moved our Cloudflare Workers near our database in Ireland, so we cross the long distance once per request instead of once per dependent stage. We shipped placement and the code changes together, followed by new indexes, so we cannot separate their contributions to production page time.

TEEI Language and TEEI Mentorship are two Workers built from [one React Router 8 codebase](/blog/one-codebase-two-programmes-react-router-8-cloudflare-workers-en/), and both use that one database. [How TEEI runs its platforms on Cloudflare Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/) gives the move one section; here is the engineering behind it, and what our production numbers can and cannot carry.

**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.

## The symptom, measured

We grouped seven days of Language page requests by entry data centre, then calculated request-weighted regional averages of the data centres&apos; median Worker times, in a measurement recorded in our report of 21 September 2026. The average was 247 milliseconds for requests that entered in Western Europe and 2,723 in Oceania, with identical code everywhere. That is the Worker&apos;s own time, not the person&apos;s time to first byte, and it does not separate distance from which pages people in each region used. But regions further from the database generally had higher Worker times, and the pattern pointed us at the distance.

Per route, the picture was sharper. From 20 September 00:00 UTC to 21 September 19:00 UTC, the learner dashboard had a median of 2,632 milliseconds in production and made 71.9 database round trips per request. Those round trips were literal: every read statement was its own HTTPS request to the database. At the time, the codebase called `.batch(` 254 times, and every one of those calls was a write.

## Two quantities: how many trips, and how long each one is

Our request log counts each fetch the database client makes and adds up the time those fetches take. Divided, that gave 123 milliseconds per trip on Language and 84 on Mentorship over seven days, measured on 21 September 2026. We timed whole fetches, and many reads ran concurrently. The figure holds TLS, the database&apos;s own processing and any queueing as well as the distance, so it is an upper bound on network time. And the dashboard&apos;s 72 trips did not run one after another: many were issued together through `Promise.all`. What set the page&apos;s time was the number of dependent stages, reads that could only start once an earlier read had returned.

That gives the problem two axes:

- **Time per trip** depends on where the Worker runs relative to the database. We act on it with placement.
- **Trips and dependent stages per page** are determined by the code. Even next to the database, we still wait for each dependent stage. We batch independent reads into fewer HTTP requests, and we remove dependent stages by rewriting loaders.

![Two panels showing one page request with several dependent stages of database reads. Before: the Worker runs in the data centre nearest the person, and every dependent stage travels the long way to the database in Ireland and back, one after another. After: the request makes one long hop to a data centre near the database, and each dependent stage then takes a short trip.](/images/blog/why-our-workers-run-near-the-database-not-the-user/one-request-placement.svg#wide,phone,fit &quot;One request, before and after placement. Before, we crossed the long distance once per dependent stage; after, we cross it once per request.&quot;)

Source: TEEI, drawn on 16 October 2026 from our performance report of 21 September 2026; no measured values.

When we run the Worker far from the database, we cross the long distance once per dependent stage. When we run it near the database, we cross that distance once per request, from the data centre that received the request to the one that runs the code, and then make a short database trip per stage.

## Fewer trips: one HTTP request for the reads of one turn

We send several SQL statements in one HTTP request, using a read-only batch. The libSQL client we use pipelines a batch into a single request over HTTP, and a batch in `&apos;read&apos;` mode opens with `BEGIN TRANSACTION READONLY`. Nothing in our codebase used that for reads.

We wrapped the client so that callers keep their existing read code. While a request runs inside a per-request scope (an `AsyncLocalStorage` store), we queue statements whose first token after whitespace is `SELECT`, and statements beginning with `WITH` when their text contains no write keyword, instead of sending them. We do not strip leading SQL comments, and the keyword check is conservative rather than a parser, so some read-only statements go out on their own. When the queue is flushed, one queued read goes out as a normal `execute`, and two or more go out as `client.batch(statements, &apos;read&apos;)` in chunks of at most 40. Each caller still gets its own result, and no call site changes.

*Adapted for publication.* (A two-line comment above `flushNow` is removed, and the excerpt stops after `execute`; `batch`, `migrate`, `transaction` and `executeMultiple` flush the queue and forward. The code shown is unchanged.)

```ts
export function coalesceReads(real: Client): Client {
  const store = scope.getStore();
  if (!store) return real;

  const flushNow = () =&amp;gt; {
    if (store.queue.length &amp;gt; 0) flush(store);
  };

  const wrapped: Client = {
    execute(stmtOrSql: InStatement | string, args?: InArgs): Promise {
      const statement = normalise(stmtOrSql, args);
      store.statements += 1;
      if (!isCoalescableRead(statement.sql)) {
        flushNow();
        return real.execute(statement);
      }
      return enqueue(store, real, statement);
    },
```

Writes, CTEs that write, `PRAGMA`, `EXPLAIN`, explicit batches, transactions, migrations and `executeMultiple` are never queued: each first flushes whatever reads are waiting, then goes straight to the real client. We start queued reads before sending a write, without waiting for their results or guaranteeing database execution order. Outside a request scope (scripts, tests, scheduled jobs) the wrapper returns the real client untouched. `coalesceReads` reads the active store when it wraps the client, so we open the scope around the Worker&apos;s fetch handler and construct the loaders&apos; client inside it.

Three limits sit right next to the mechanism. We collect reads within each request, without giving the whole loader a shared transaction or snapshot: each submitted batch has its own read transaction, and we split a flush of more than 40 reads into chunks that we start without awaiting one another, so statements stay sequential within each batch while separate batch requests can overlap. We combine transport, not duplicate reads: the same read issued twice is still two SQL statements. And we submit each batch through its first entry&apos;s client; our Workers read one database, but with more than one, keep a separate queue for each client target.

### When the queue leaves

The obvious end of a turn, a microtask, is too early:

*Adapted for publication.* (The comment is shortened to what our tests show; the code is unchanged.)

```ts
function defaultSchedule(flush: () =&amp;gt; void): void {
  // A macrotask, not a microtask: a timer of zero lets reads issued across
  // immediately resolved microtasks join the queue before the batch leaves.
  // Reads issued after this timer fires are handled by a later flush.
  setTimeout(flush, 0);
}
```

A loader that runs `Promise.all` over five functions can issue its reads a few microtasks apart: one function awaits a hash, another a cached value. A zero-delay timer fires only once the microtask queue has drained, so those reads join one batch, while work that waits on anything slower may miss the timer and form a batch of its own. Each request has its own store; we test that two requests interleaving on one isolate never share a batch or a result. We queue a lone read too, and when the timer fires it goes out through `execute`, so on that turn the queue saves no network request.

We can queue a read only once the code issues it, and a dependent read only after the earlier result arrives. We preserve read dependencies, which is also why batching stopped at 26.

## Why batching stopped at 26

We measured the two code changes separately on a development server, without placement, on 21 September 2026: trips per route before the day&apos;s work, with only the batching wrapper, and with the loader rewrites as well.

![Grouped bar chart of database round trips per request on a development server on 21 September 2026, at three points in one day’s work and with no placement involved. The learner dashboard went from 84 trips to 26 with read batching and to 6 with the loader rewrites. The other routes: the volunteer dashboard from 48 to 13 to 8, settings from 39 to 9 to 9, group sessions from 34 to 8 to 7, the volunteer list from 29 to 11 to 5, and bookings from 18 to 3 to 3.](/images/blog/why-our-workers-run-near-the-database-not-the-user/trips-per-route.svg#phone,fit &quot;Fewer trips came from code, not from placement: round trips per request on a development server, 21 September 2026. We removed most trips on most routes by batching; on the learner dashboard, we rewrote loaders to get from 26 to 6.&quot;)

Source: TEEI, from development-server measurements of database round trips per request in our performance report, read on 21 September 2026.

On most routes, we removed most of the trips with batching: the bookings page went from 18 to 3. The learner dashboard went from 84 to 26, and those 26 were dependent stages. Most of its reads depended only on the session, but the loader awaited them one after another, and no scheduler can batch a read that has not been issued yet. With batching alone, the page took 1.7 to 2.3 seconds on the development server.

So we rewrote the loader. Seventeen of its reads depend only on the session, the programme and one flag; we now start them together, as one wave, once their existing conditions are known, and await them where the loader already handles their results. Where it read the occupancy of each accepted booking separately, it now reads all of them in one statement. That took the dashboard from 26 trips to 6, and the page to 0.4 to 0.6 seconds on the same server.

Moving reads to the top of the loader broke six route tests the first time. A read called there has left the `try/catch` that owns its failure, so a read that throws synchronously escapes as a server error instead of the empty fallback its block is meant to render. Our helper accepts a function so that a synchronous throw becomes a rejected promise and reaches the existing `try/catch` at `await`:

*Adapted for publication.* (A lint annotation above the last `catch` is removed; the code is unchanged.)

```ts
function startRead(read: () =&amp;gt; Promise): Promise {
  let promise: Promise;
  try {
    promise = read();
  } catch (err) {
    promise = Promise.reject(err);
  }
  promise.catch(() =&amp;gt; undefined);
  return promise;
}
```

We attach the last `catch` to the original promise and discard the derived promise it returns. The original still rejects at the caller&apos;s own `await`; because we attach the handler as the read starts, a rejection before that `await`, or in a block never reached, is not reported as unhandled.

We count SQL statements so batching cannot hide a read per booking. One network exchange can still carry one read per booking, so the regression test counts statements, not HTTP requests, for one, five and twenty bookings:

*Adapted for publication.* (An internal ticket reference in the last comment is replaced with &quot;this change&quot;.)

```ts
describe(&apos;/home loader statement count does not grow with the booking list&apos;, () =&amp;gt; {
  it(&apos;issues the same number of statements for 1, 5 and 20 accepted bookings&apos;, async () =&amp;gt; {
    const forOne = await loaderStatementsFor(1);
    const forFive = await loaderStatementsFor(5);
    const forTwenty = await loaderStatementsFor(20);

    // The guard that makes the equalities meaningful: the loader really is reading.
    expect(forOne).toBeGreaterThan(0);
    // Twenty bookings, one occupancy read. Before this change this was forOne + 19.
    expect(forFive).toBe(forOne);
    expect(forTwenty).toBe(forOne);
  });
});
```

The positive assertion prevents a vacuous pass: if the loader stopped reading altogether, all three counts could be zero and the equality checks would still pass. When we restored the per-booking reads, the test went red.

### One slow statement holds up its batch

Batching does not make statements run in parallel on the server. The wrapper&apos;s own documentation says so: &quot;Turso runs the batch sequentially on one connection; the win is the network exchange, not the SQL.&quot; We saw what that means on the first morning after the release. Walking the platform signed in from Oslo, most pages took 17 to 27 milliseconds per trip. Two did not: the volunteer list and the learner dashboard each had one trip of 519 to 1,109 milliseconds, while the other trips on the same page were fast.

It was not distance but one statement: a correlated count, run once per volunteer on the list, that walked every completed booking. We resolve callers after the whole batch returns, so one slow statement also delays faster results. We rewrote it as one grouped pass, and it went out in the next release the same morning. We then logged completed read batches taking at least 250 milliseconds, with each statement&apos;s shape. A slow fetch in our logs may now hold several reads; to find the expensive one, we timed the candidate statements one by one. Running nearer the database did not remove that statement&apos;s repeated work.

## Shorter trips: running the fetch handler next to the database

[Placement](https://developers.cloudflare.com/workers/configuration/placement/) has two forms. With Smart Placement, Cloudflare measures a Worker&apos;s traffic and runs it elsewhere if another location measures better. With a placement region, you name the cloud region your backend lives in, and Cloudflare runs the Worker in the data centre with the lowest latency to it.

We tried Smart Placement first, on staging, starting on 31 August 2026. The staging Worker received about 175 requests in a week, nearly all through Oslo, and the trial never reached a decision. The documentation says Smart Placement needs consistent traffic from several locations; we did not establish why ours did not conclude. We knew the database&apos;s region, so on 21 September we named it as our placement target rather than waiting for a traffic-based decision:

*Verbatim excerpt.*

```jsonc
  &quot;placement&quot;: {
    &quot;mode&quot;: &quot;targeted&quot;,
    &quot;region&quot;: &quot;aws:eu-west-1&quot;
  },
```

The `mode` key is optional, and we write it out so that nobody reading the file mistakes it for Smart Placement. The same four lines sit in every named environment of both Workers. We do not rely on inheritance, because a measurement on staging or a preview is only worth something if it describes what production runs.

Three limits decide what the setting can touch. It affects fetch handlers only, not RPC methods or named entrypoints, so our cron jobs in `scheduled()` run where they always did. Static assets are still served nearest the person, as long as the asset layer answers before the Worker. And Cloudflare places Durable Objects by a separate mechanism, so our live presence object, described in [Real-time availability with Durable Objects](/blog/real-time-availability-with-durable-objects-en/), is not affected.

We test for mixed placement settings before deployment. A contract test reads both Wrangler files and fails if any environment loses the block, mixes the `smart` and `targeted` forms, or sets `run_worker_first` to true, which would send even static-asset requests through the placed Worker and make every JavaScript chunk and image wait on the forwarding hop.

A declared target is not a measurement, so we asked Cloudflare&apos;s GraphQL Analytics API which data centres executed the invocations. The numbers are estimates from sampled analytics.

![Bar chart of where the two Workers ran. From 14 to 20 September 2026, Language ran in 107 data centres, with 2.24 percent of its invocations in London, and Mentorship in 59, with 1.14 percent in London. From 8 to 14 October 2026, Language ran in 13 data centres, with 98.89 percent of its invocations in London, and all but one of Mentorship’s 76,285.](/images/blog/why-our-workers-run-near-the-database-not-the-user/where-it-ran.svg#phone,fit &quot;Where the code ran, before and after the placement region was declared; sampled, so each value is an estimate.&quot;)

Source: TEEI, from Cloudflare’s GraphQL Analytics API, Workers invocations by data centre, read on 15 October 2026.

From 14 to 20 September 2026, 2.24 percent of the Language Worker&apos;s invocations ran in London. From 8 to 14 October 2026, 98.89 percent did, and the Mentorship Worker ran 76,284 of its 76,285 there. Only Language has cron triggers, and its remaining 1.1 percent is close to a week of scheduled runs; that these are the scheduled jobs is our inference, since the analytics do not split invocations by handler type.

## What the release changed, and what we can credit to what

Placement, read batching and the loader rewrites went to production together on 22 September 2026, in one release that also throttled background bookkeeping writes and carried unrelated work. Minutes later we applied a migration with seven indexes. One index changed a query plan from a full scan to an indexed search for a statement used on signed-in requests. That can reduce database-side work independently of where the Worker runs; we did not time its contribution separately.

Table: Language Worker GET page requests that returned 200 and made at least one database fetch, timed inside the Worker with the duration our application logs when a request completes. Both weeks come from the Worker logs that Logpush delivers to R2, read on 24 October 2026 from a random sample of the log files, 11.5 percent of them for the week before and 12.1 percent for the week after, so the request counts are estimates; median and 90th percentile are taken over each week as a whole.

| Window | Requests | Median | 90th percentile | Trips per page | ms per trip |
|---|---|---|---|---|---|
| 7 days before the release | about 97,000 | 433 ms | 1,747 ms | 17.1 | 123 |
| 7 to 14 October 2026 | about 132,000 | 177 ms | 462 ms | 5.8 | 55 |

**Trips per page, 17.1 to 5.8: code, not placement.** Placement does not remove database trips. Our development measurements isolate the reductions from batching and the loader rewrites; the October figure also reflects later code and a different traffic mix.

**Time per trip, 123 to 55 milliseconds: placement is the likely main mechanism, but its share is not documented.** For placement: before the release, selected requests on a placed preview Worker that already contained the code changes averaged 17 to 33 milliseconds per fetch (one test account, a small test dataset, one evening), and from 8 to 14 October 2026 more than 98 percent of invocations ran in London, against under 3 percent before. Against crediting placement alone: the new indexes changed query plans and may also have reduced time per trip, and batching puts more statements into each trip (18.9 statements on 5.8 trips per page in October), while some loader rewrites removed statements and others added a few. Nothing we have separates the contributions.

**Page time, a median of 433 to 177 milliseconds and a 90th percentile of 1,747 to 462: the combined effect.** Both pairs are weekly percentiles. The difference includes placement, the code changes, the indexes, everything we have shipped since, and a different mix of traffic in October.

We also cannot show that the regional gap from September closed. We measured October differently, and we have no measurement of a person&apos;s own time to first byte outside Oslo, before or after.

## What placement costs

Before the release, from Oslo, we measured the hop on an endpoint that never touches the database: a median time to first byte of 231 milliseconds on the placed preview against 99 in production, a difference of about 130 milliseconds, which is an upper bound on the hop from Oslo. On an endpoint that does read the database the two were level, 193 milliseconds placed and 211 in production, but the preview and production also differed in code and in database, so that is not a placement result. For a person far from Europe, the first database trip always crossed the long distance; placement moves that crossing to the start of the request, and the gain is every dependent stage after the first. We did not measure the hop from Tokyo, Johannesburg or Dallas.

## When this is the wrong choice

**Your database is replicated or anycast.** The documentation recommends a placement region for single-homed infrastructure. If reads are served near the person already, moving the Worker away from the person adds the hop and saves little.

**Your Worker makes one database call per request.** With one trip there is no repeated long exchange to remove: the long distance is still crossed once, only at a different point, and the forwarding has costs of its own. Measure the hop against the database trip it replaces before you adopt it.

**You were planning to split the Worker.** Cloudflare&apos;s documented pattern puts a light Worker near the person and a backend Worker near the database, and it assumes the edge Worker can authenticate a request without the backend. We built that split and tested it, and did not deploy it: every signed-in page on our platforms needs the database before it can answer, so the split would move the first trip rather than remove it, and it adds a second Worker to own and roll out. For the split we tested, we forwarded with the service binding&apos;s HTTP `fetch` interface rather than RPC, because the placement setting covers fetch handlers, not RPC methods or named entrypoints.

## Verify this

- **See where our Worker runs (public).** Send `curl -sI https://language.theeducationalequalityinstitute.org/`, or the same to `https://mentorship.theeducationalequalityinstitute.org/`. On 15 October 2026 from Oslo, both answered with `cf-placement: remote-LHR` and a `cf-ray` ending in `OSL`: the request entered in Oslo and the Worker ran near London.
- **Reproduce it in your own project (public).** Add a `placement` block with your database&apos;s region to a Worker that makes several dependent queries, and log database fetches and their summed time per request. With the code, the database, the route and the inputs fixed, and both versions measured over the same hours, compare database time per fetch and the response&apos;s time to first byte, with the block present and removed. The first measures the database exchanges; the second includes the forwarding. A placement header confirms where the Worker ran, not whether the request became faster. A comparison with identical code is the one we could not make.
- **Our trip counts, time per trip and execution locations (internal).** These come from our Workers Logs and Cloudflare analytics and are documented internally; a reader cannot check them.</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Cloudflare Workers</category><category>Placement</category><category>libSQL</category><category>Performance</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>We Pruned 12,115 Astro Redirect Pages from Our Build</title><link>https://theeducationalequalityinstitute.org/blog/why-we-moved-astro-redirects-out-of-the-build-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/why-we-moved-astro-redirects-out-of-the-build-en/</guid><description>Pruning Astro fallback pages reduced the files we shipped. Redirecting their old URLs needed a separate fix before the Worker.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>On 25 July 2026 we cut our Astro build output from 22,618 files to 10,503 by pruning 12,115 fallback pages for Ukrainian and Norwegian city addresses. Those pages contained English fallback redirects, not translations. Removing their files did not tell a reader following an old link where to go. Our middleware rule returned 301 on the dev server and 404 in the production build, so we put the city redirects in a Cloudflare rule before the Worker.

This case has two independent outcomes to verify: the files left after a build and the HTTP answer at each old URL. Astro still generates the fallback pages; we exclude them from compression, prune them afterwards and check the resulting file count. The zone rule supplies the redirect. Neither change proves that the other works.

This article follows [our Astro website architecture](/blog/our-homepage-is-a-live-system-astro-cloudflare-workers-en/). [Worker first and adapter routing](/blog/worker-first-or-assets-first-routing-astro-on-cloudflare-en/) explains which code sees a request; here the question is how to reduce generated output while keeping old addresses useful. The wider platform is in [How TEEI runs its platforms on Cloudflare Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/).

**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.

## Three changes, three different effects

| Change | What it changes | What still happens |
|---|---|---|
| Exclude locale-prefixed city trees from compression | Fewer files go through the compressor | Astro generates those pages; the exclusion does not remove them |
| Prune recognised fallback pages after Astro builds | Fewer static asset files remain to ship | Their old URLs still need an HTTP answer |
| Redirect at the Cloudflare zone | A matching request gets a 301 before the Worker | The rule does not change the build output |

We check file counts after pruning. We check redirects separately for status, destination, query preservation and hop count. This separation also matters for the limits: these HTML pages are static asset files, counted per Worker version. We did not measure a reduction in the Worker script bundle.

## How two fallback locales multiply the page count

Our site is in English at the root, with Ukrainian and Norwegian under `/uk/` and `/no/`. Most pages exist only in English, so the Ukrainian and Norwegian paths fall back to the English page:

*Adapted for publication.*

```js
i18n: {
  defaultLocale: &apos;en&apos;,
  locales: [&apos;en&apos;, &apos;uk&apos;, &apos;no&apos;, &apos;de&apos;, &apos;fr&apos;, &apos;es&apos;, &apos;pl&apos;, &apos;cs&apos;, &apos;it&apos;, &apos;nl&apos;, &apos;pt&apos;, &apos;da&apos;, &apos;lv&apos;],
  routing: {
    prefixDefaultLocale: false, // English pages at root (/programs/), not /en/programs/
    fallbackType: &apos;redirect&apos;, // Explicit (was implicit default); pins redirect behaviour for the uk/no fallback.
  },
  fallback: {
    uk: &apos;en&apos;,
    no: &apos;en&apos;,
  },
},
```

On 15 October 2026 we declared 13 locales, with fallback to English configured only for Ukrainian and Norwegian. Those two relationships set the multiplier, not the length of the locale list. [Astro&apos;s documentation](https://docs.astro.build/en/guides/internationalization/#fallback) describes the fallback: for every route a fallback locale is missing, we get a page in that locale. In our build each one is a small HTML file that answers 200, with a `` that moves the reader to the English page after two seconds, a `noindex` robots tag and a canonical link to the English page. That is the format Astro&apos;s documentation describes for [redirects in a static build](https://docs.astro.build/en/guides/routing/#redirects).

Volunteers find us through a page for each city, a page for each country, a hub that lists them, and a page for each programme that recruits in a city. On 15 October 2026 our city manifest listed 2,000 cities, 58 countries and two programmes with city pages, TEEI Language and TEEI Mentorship, at 2,000 cities each.

![Diagram of the arithmetic: 2,000 city pages, 58 country pages and one hub make 2,059, and 2,000 TEEI Language and 2,000 TEEI Mentorship city pages make 4,000, so 6,059 pages without a prefix. Each gets a Ukrainian and a Norwegian fallback page, 12,118 in all. Our pruning step removed 12,115 on 25 July 2026.](/images/blog/why-we-moved-astro-redirects-out-of-the-build/arithmetic.svg#wide,phone,fit &quot;How we generate two fallback redirect pages for each recruitment page. The page counts are from our city manifest as read on 15 October 2026; the difference of three against the 12,115 we removed in July is not explained.&quot;)

Source: TEEI, from our city manifest, read on 15 October 2026 at 12:47 UTC, and our pruning step’s record of 25 July 2026.

That makes 6,059 pages without a prefix, and each gets a Ukrainian and a Norwegian fallback page: 12,118 extra files by the manifest&apos;s arithmetic. Our pruning step removed 12,115 on 25 July 2026. We have not explained why the counts differ by three.

## Why our middleware rule missed the city paths

In the July investigation, no page of ours linked to these locale-prefixed city addresses and no sitemap listed them. We still wanted a redirect for anyone arriving at one. Three candidate locations in the Astro project failed to give us the production answer we needed:

**A `_redirects` file.** We get one in the build from the [Astro Cloudflare adapter](https://docs.astro.build/en/guides/integrations-guide/cloudflare/#cloudflare-platform). But [Cloudflare&apos;s documentation](https://developers.cloudflare.com/workers/static-assets/redirects/) is explicit: &quot;Redirects defined in the `_redirects` file are not applied to requests served by your Worker code, even if the request URL matches a rule defined in `_redirects`.&quot; Our build configuration uses `run_worker_first = true`, and our own `_redirects` file has carried a header saying it is not processed since 10 May 2026. That header describes our setup; `_redirects` remains a supported feature of the static asset layer.

**Astro&apos;s `redirects`.** Our configuration has an empty `redirects: {}` with a comment from May 2026: a redirect whose source uses a `[...rest]` spread parameter made the adapter&apos;s redirect helper emit a rule that pointed the route back at its own pattern, which Cloudflare rejected as a loop. Stripping a prefix from every city path is the kind of redirect that needs one.

**Our own middleware.** We wrote a middleware rule that strips the `/uk` or `/no` prefix from a city path and answers 301. On the dev server it does. In the production build it did not, which we traced on 25 July 2026 in the built Worker and on staging: `/uk/cookie-policy`, which a different rule earlier in the same middleware redirects, answered 301, while `/uk/volunteer/cities/zzz/`, a path that never had a redirect page, answered 404. Our reading is that Astro&apos;s locale routing answered those paths with the 404 page before our middleware ran. We saw the behaviour; we have not traced that order through Astro&apos;s source. The rule is still in the middleware, and it answers on the dev server.

The observed 301/404 difference was enough to rule out middleware as the only place for these city redirects. We placed their HTTP answer before the Worker. The pruning step remained a separate part of the build.

## The file ceiling we chose to enforce

As we read [Cloudflare&apos;s limits page](https://developers.cloudflare.com/workers/platform/limits/) on 15 October 2026, it gives 20,000 static asset files per Worker version on the Free plan and 100,000 on Paid with Wrangler 4.34.0 or later, a change [Cloudflare announced on 4 September 2025](https://developers.cloudflare.com/changelog/post/2025-09-02-increased-static-asset-limits/). We keep a ceiling of 20,000 files; we have no evidence that Cloudflare rejected this build. The reason in our own notes applies on any plan: generating and processing pages we never ship adds build work.

## Prune only the fallback-page format

We run the HTML through `@playform/compress` after Astro has written it, and compressing pages we were about to delete was work on files that never ship. We exclude the two locale-prefixed city trees by path, without inspecting their HTML:

*Verbatim excerpt.*

```js
      Exclude: [(file) =&amp;gt; /(?:^|[\\/])(?:uk|no)[\\/]volunteer[\\/]cities[\\/]/.test(file)],
```

In the comment beside it, which we read on 15 October 2026, we report that this cut the compression pass from about 19,000 files to 5,040. That is a file count; we did not measure how much build time the exclusion saved.

Right after Astro&apos;s build, before anything measures the output, we walk the Ukrainian and Norwegian copies of the city tree in a small Node script and delete a file only when it matches the redirect-page format:

*Verbatim excerpt.*

```js
function isRedirectStub(html) {
  return (
    /]*noindex/i.test(html) &amp;amp;&amp;amp;
    /rel=&quot;canonical&quot;/i.test(html) &amp;amp;&amp;amp;
    html.length &amp;lt; 2000
  );
}
```

All four conditions must hold. A failed condition keeps the file; we count those kept files and print up to ten paths. This recognises a particular HTML format, not translation status: `html.length` is JavaScript string length, and the canonical check is a case-insensitive text match, not a parsed link or a comparison with the expected target. The compression exclusion is broader because it checks only the path, so a page pruning keeps in that tree remains uncompressed.

The deletion runs in two passes because of a bug we had. An earlier version deleted each city&apos;s directory as it went, but a city directory holds its own page and the directories of the programme pages under it, so the first deletion removed files that were still on the list, and every later read threw `ENOENT`. Now we collect only files named `index.html` and read every candidate before deleting anything. Then we delete the matching files one by one and remove a directory only once it is empty, deepest first. A file we keep therefore keeps its directory, and a read error stops the build before the first deletion.

The pruning covers one surface on purpose: the Ukrainian and Norwegian copies of the city pages. Every other page Astro&apos;s fallback writes is still built.

## Compare projected capacity with the output on disk

We can project the file count before a build and check the output after pruning. For the projection we count the build output that exists, subtract its unprefixed city tree and add the pages the city manifest projects in its place; with no output on disk, we use a dated baseline. Locale-prefixed files still on disk stay in the baseline, so the result before a build is an estimate, not a dry run of Astro. A programme that is a draft, paused or retired, or that has no city pages, adds nothing, which lets us set up a new programme&apos;s cities before it opens without spending the budget. Since 25 July 2026 we warn above 16,000 files and fail the build above 18,000, with 20,000 as the hard ceiling.

The projection and the measured total can disagree, so after pruning, before our remaining clean-up steps, we stop the build when either exceeds the release limit:

*Adapted for publication.*

```js
const measuredVerdict =
  measured &amp;amp;&amp;amp; measured.files &amp;gt; RELEASE_LIMIT ? &apos;fail&apos; : &apos;ok&apos;;

if (report.verdict === &apos;fail&apos; || measuredVerdict === &apos;fail&apos;) {
  process.exit(1);
}
```

We test the projection with hypothetical fleets: five full-scale programmes warn, a sixth fails while still below our 20,000 ceiling, and inactive programmes add no pages.

On 15 October 2026 at 12:47 UTC a build on our build machine held 12,613 files, and the gate&apos;s verdict was ok. Our projection replaces 6,059 counted city files with 6,059 projected pages, leaving the total at 12,613; equal counts do not prove that the routes match or validate the fallback arithmetic. With the redirect pages, the same build would have held a calculated 24,731 files, above our release limit, and the gate would have failed it.

![Bar chart of files in our website’s build output. On 25 July 2026 the build held 22,618 files with the redirect pages and 10,503 after pruning. On 15 October 2026 it held 12,613 after pruning, and would have held 24,731 with them, a calculated figure. Lines mark 16,000, above which we warn, 18,000, above which the build fails, and 20,000, our ceiling.](/images/blog/why-we-moved-astro-redirects-out-of-the-build/build-files.svg#wide,phone,fit &quot;Files in the website’s build output, with and without Astro’s fallback redirect pages for the city pages. The 24,731 is calculated, not built. As we read Cloudflare’s documentation on 15 October 2026, it gives 20,000 static asset files per Worker version on the Free plan and 100,000 on Paid. The October count is from one build, measured at 12:47 UTC.&quot;)

Source: TEEI, from our pruning step’s record of 25 July 2026 and our capacity gate’s count of one build on 15 October 2026 at 12:47 UTC, read on 15 October 2026.

## Give old URLs a redirect before the Worker

We rely on Cloudflare&apos;s fixed order: [Single Redirects](https://developers.cloudflare.com/rules/url-forwarding/single-redirects/) first, [Bulk Redirects](https://developers.cloudflare.com/rules/url-forwarding/bulk-redirects/) later, and Bulk Redirects, in the words of the documentation, &quot;will run in front of your Worker&quot;. When a Single Redirect matches, we answer at that layer, before our Worker, Astro and the assets. To follow its `Location`, the reader makes a new request; the original request never reaches Bulk Redirects or our Worker.

![Diagram of a request passing four layers in order: Single Redirects on the zone, Bulk Redirects on the account, the Worker’s entry point and then the static files and Astro. Locale-prefixed city and blog addresses are answered with a 301 at the first layer, an old WordPress address at the second, exact old addresses kept in our code at the third, and pages at the fourth.](/images/blog/why-we-moved-astro-redirects-out-of-the-build/layers.svg#wide,phone,fit &quot;Where we answer an old address on our website: Single Redirects before Bulk Redirects, both before the Worker. Example answers measured on 15 October 2026.&quot;)

Source: TEEI, drawn from Cloudflare’s documentation and our website’s configuration, with example answers measured on 15 October 2026.

The following expressions and settings are copied from our infrastructure notes; they describe Cloudflare rules, not a Wrangler configuration file. On 25 July 2026 we added this city rule to the zone:

*Verbatim excerpt.*

```
(starts_with(http.request.uri.path, &quot;/uk/volunteer/cities&quot;) or
 starts_with(http.request.uri.path, &quot;/no/volunteer/cities&quot;))
-&amp;gt;
concat(&quot;https://theeducationalequalityinstitute.org&quot;,
       regex_replace(http.request.uri.path, &quot;^/(uk|no)&quot;, &quot;&quot;))
preserve_query_string: true
```

The match is a plain prefix test. The target strips the locale prefix and keeps everything after it, and the query string goes with it.

The same pattern came back on 12 October 2026. External sites linked to Ukrainian and Norwegian addresses of English blog posts, such as `/uk/blog/-en/`, and through Astro&apos;s fallback we served those as 200 pages with a meta refresh, not as a 301. Ahrefs listed 82 such linked addresses that day. Those fallback pages are still built, so this time the build did not change at all. A second rule answers first:

*Verbatim excerpt.*

```
(http.request.uri.path matches &quot;^/(uk|no)/blog/[^/]+-en/?$&quot;)
-&amp;gt;
concat(&quot;https://theeducationalequalityinstitute.org&quot;,
       regex_replace(http.request.uri.path, &quot;^/(?:uk|no)/blog/([^/]+-en)/?$&quot;, &quot;/blog/${1}/&quot;))
preserve_query_string: true
```

Unlike the city rule&apos;s prefix test, this rule anchors the whole path: one slug segment ending in `-en`, with an optional trailing slash, which we rewrite to the English post with a trailing slash. A post we have translated carries its own suffix, and the Ukrainian and Norwegian blog hubs are left alone. On 12 October all 82 linked addresses answered 301 to the English post in one hop. When we checked again on 15 October 2026, the city and blog addresses answered 301 with the query string intact, and other addresses under `/uk/`, such as `/uk/volunteer/teei-language/`, still answered 200.

## Choose where to maintain the redirect

**We maintain our zone rules outside the repository.** We record their expressions and dated response checks in our infrastructure notes; our test suite does not exercise the live rules. If you need a redirect reviewed with the code, put it in the Worker entry. Most of ours are exact old addresses, and for those we wrap the adapter&apos;s Worker entry: at build time we copy the redirect maps from our middleware into it, and we look up each path there before Astro sees the request. Because `run_worker_first` is on, that lookup runs before any file is served. A redirect kept in two layers can drift: our old WordPress list lives both in the account and as a map in our middleware, and our notes say to change both or neither.

**Single Redirects are few.** On 15 October 2026 we read in Cloudflare&apos;s [URL forwarding documentation](https://developers.cloudflare.com/rules/url-forwarding/) an allowance of 10 to 300 Single Redirect rules per zone, depending on the plan, and regular expressions are not available on every plan. They suit a handful of pattern rules. A long list of fixed addresses belongs in Bulk Redirects, which have no regular expressions or string replacement, or in your Worker.

**Prune when reducing shipped output solves a measured problem.** A file count comfortably inside your plan&apos;s allowance may not justify another build step. Count files before and after pruning and time the build separately: deleting output does not avoid generating it. Check HTTP behaviour independently; a 200 page with meta refresh and a 301 response remain different answers even when file capacity is ample. Disabling fallback changes the generation step and needs its own comparison.

**`fallbackType: &apos;rewrite&apos;` does not reduce the count.** It serves the English content on the localised address in place of a redirect page. By Astro&apos;s documentation, we would still get a page for every missing route in every fallback locale. We have not built our site that way, and we have not measured it.

## Verify file count and HTTP behaviour separately

At the build steps immediately before and after pruning, count the same output directory. For an Astro build with client assets in `./dist/client`:

```bash
find ./dist/client -type f | wc -l
```

Keep those counts with the build&apos;s timestamp. Compare routes separately if you need to prove which pages remain; equal totals alone do not do that. For this case, the recorded 25 July 2026 counts were 22,618 before pruning and 10,503 afterwards.

For HTTP behaviour, these public requests were measured on 15 October 2026. Repeating them checks the site&apos;s answer at the time you run them; it does not reproduce the historical build count.

In that capture, the Ukrainian volunteer page answered 301 to the page without the prefix:

```bash
curl -sI https://theeducationalequalityinstitute.org/uk/volunteer/cities/norway/oslo/
```

With a query string we saw one hop, a final 200 and a final address without the prefix that still ends in `?a=b`:

```bash
curl -sIL -o /dev/null -w &apos;%{num_redirects} %{http_code} %{url_effective}\n&apos; \
  &quot;https://theeducationalequalityinstitute.org/uk/volunteer/cities/norway/oslo/language/?a=b&quot;
```

A Norwegian address for an English post showed one hop, a final 200 and the English post as the final address:

```bash
curl -sIL -o /dev/null -w &apos;%{num_redirects} %{http_code} %{url_effective}\n&apos; \
  https://theeducationalequalityinstitute.org/no/blog/bain-for-ukraine-en/
```

## Appendix: configuration

Versions on 15 October 2026: Astro 7.2.10, `@astrojs/cloudflare` 14.2.4, Wrangler 4.107.0.

The website&apos;s assets block:

*Adapted for publication.*

```toml
[assets]
binding = &quot;ASSETS&quot;
run_worker_first = true
not_found_handling = &quot;404-page&quot;
```

Count the output of the build you intend to ship, then probe the old URLs against the environment you intend to release. For the redirect, inspect the first response&apos;s status and `Location` as well as the final destination: following a link successfully does not by itself establish a one-hop 301 or query preservation.

## Continue in the Astro series

The [homepage case](/blog/our-homepage-is-a-live-system-astro-cloudflare-workers-en/) follows what happens after a reader reaches the page. Continue with [deploying the exact Worker you tested](/blog/deploying-the-exact-worker-you-tested-en/) for how generated output, configuration and release evidence fit together.</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Astro</category><category>Cloudflare Workers</category><category>Redirects</category><category>i18n</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>Worker First Is Not Middleware First: Astro on Cloudflare</title><link>https://theeducationalequalityinstitute.org/blog/worker-first-or-assets-first-routing-astro-on-cloudflare-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/worker-first-or-assets-first-routing-astro-on-cloudflare-en/</guid><description>In our Astro setup, we got 404s for old image URLs. We trace why we needed redirects before the adapter, even with Worker first.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>On 6 October 2026, all 14 old image URLs we checked returned 404 in production, although we had redirects for them in Astro middleware. We had already enabled `run_worker_first = true`. The requests reached our Worker, but the adapter and Astro returned our prerendered 404 page without running middleware. We moved the redirects into the Worker entry, before the adapter.

The placement matters whenever a redirect or header must cover built files, prerendered pages and missing paths as well as on-demand routes. There are two routing decisions to follow: Cloudflare decides whether to invoke the Worker; once invoked, the adapter decides whether to render an Astro route. We traced both in Astro 7.2.10, `@astrojs/cloudflare` 14.2.4 and Wrangler 4.107.0, as installed on 15 October 2026. The results below apply to those versions with a prerendered 404 page.

This is the request-routing part of [our Astro website architecture](/blog/our-homepage-is-a-live-system-astro-cloudflare-workers-en/). The wider platform is described in [How TEEI runs its platforms on Cloudflare Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/).

**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.

## First decision: does Cloudflare invoke the Worker?

Cloudflare&apos;s [Static Assets routing](https://developers.cloudflare.com/workers/static-assets/) defaults to assets first: when a URL resolves to a built file, the asset layer answers without invoking Worker code. With [`assets.run_worker_first = true`](https://developers.cloudflare.com/workers/static-assets/binding/), the Worker runs before that lookup. We first deployed the website as a Worker on 9 May 2026, next to its Pages project and before any traffic moved, and use this setting to retain the request order we had on Pages; [Pages ran Functions ahead of assets by default](https://developers.cloudflare.com/workers/static-assets/migration-guides/migrate-from-pages/).

For assets-first routing, the [compatibility date and flags](https://developers.cloudflare.com/workers/configuration/compatibility-flags/) also affect requests that do **not** match a file. From compatibility date 2025-04-01, the default is for a request carrying `Sec-Fetch-Mode: navigate` to prefer the asset layer even when no asset matches. With our reproduction&apos;s `not_found_handling = &quot;404-page&quot;`, that returns the built 404 page instead of invoking the Worker. A browser navigation to an on-demand route can therefore fail while plain curl succeeds.

The date selects the default. The explicit flags &lt;code&gt;assets_&lt;wbr&gt;&lt;/wbr&gt;navigation_&lt;wbr&gt;&lt;/wbr&gt;prefers_&lt;wbr&gt;&lt;/wbr&gt;asset_&lt;wbr&gt;&lt;/wbr&gt;serving&lt;/code&gt; and &lt;code&gt;assets_&lt;wbr&gt;&lt;/wbr&gt;navigation_&lt;wbr&gt;&lt;/wbr&gt;has_&lt;wbr&gt;&lt;/wbr&gt;no_&lt;wbr&gt;&lt;/wbr&gt;effect&lt;/code&gt; enable or disable that navigation behaviour. With `run_worker_first = true`, it has no effect.

![Decision diagram for the reproduction’s 404-page configuration with the default compatibility flags. If run_worker_first is true, the Worker runs for every request. If it is false, a request whose URL resolves to a built asset, including HTML path handling such as a folder URL finding its index.html, is answered by the asset layer. A request with no matching asset goes to the Worker, unless the compatibility date is 2025-04-01 or later and the request is a browser navigation, in which case the asset layer answers with the 404 page.](/images/blog/worker-first-or-assets-first-routing-astro-on-cloudflare/who-answers.svg#wide,phone,fit &quot;Who answers a request on Workers with static assets. The navigation branch only exists with run_worker_first set to false and a compatibility date of 2025-04-01 or later. For the reproduction’s 404-page configuration and default compatibility flags. From Cloudflare’s documentation, each branch reproduced with HEAD requests on 15 October 2026.&quot;)

Source: TEEI, from Cloudflare&apos;s Static Assets, binding and compatibility flags documentation, each branch reproduced on 15 October 2026.

### A browser-only 404, reproduced

Our local project has a prerendered page, an on-demand page and a prerendered 404 page. The Worker adds `x-worker: ran`; middleware adds `x-middleware: ran` to responses that pass back through it. On 15 October 2026 we sent each path two HEAD requests in `wrangler dev`: one with `Sec-Fetch-Mode: navigate`, one without it.

Table: HEAD probe results, 15 October 2026. Where navigation and plain requests differ, both are given.

| Setting | Compatibility date | `/static/` (prerendered) | `/dynamic` (on demand) | `/nope/` (no route) |
|---|---|---|---|---|
| `run_worker_first = true` | 2024-09-23 | 200, Worker | 200, Worker and middleware | 404, Worker |
| `run_worker_first = false` | 2024-09-23 | 200, neither | 200, Worker and middleware | 404, Worker |
| `run_worker_first = false` | 2025-04-02 | 200, neither | Navigation: 404, neither. Plain: 200, Worker and middleware | Navigation: 404, neither. Plain: 404, Worker |
| `run_worker_first = true` | 2025-04-02 | 200, Worker | 200, Worker and middleware | 404, Worker |

The third row isolates the browser-only failure: the navigation header changes `/dynamic` from a rendered 200 into an asset-layer 404. All rows use `not_found_handling = &quot;404-page&quot;` and the default navigation flags; we did not compare `&quot;none&quot;`. These probes identify routing, not preservation of the HEAD method: the adapter&apos;s asset helpers receive `request.url` rather than the original `Request`.

The second row also disproves a comment we kept until 15 October: Worker first is not required for Astro SSR. The on-demand route renders with `false` at compatibility date 2024-09-23, the date in our configuration read that day. We corrected the comment. This reproduction does not establish the cause of the historical 404s mentioned in it; we found no request trace for those, and our date predates the navigation change.

## Second decision: does the adapter render a route?

Once the Worker runs, it can answer in its own entry or call the adapter. Reaching the adapter still leaves three possible outcomes: it returns a built file, renders an on-demand route with middleware, or asks Astro for a prerendered error page without middleware. The deciding branches in `@astrojs/cloudflare` 14.2.4 are:

*Adapted for publication.* We omit the session setup and the development-mode branch and remove the function&apos;s indentation; the retained lines are otherwise unchanged.

```js
const staticAsset = matchStaticAsset(app.manifest, request.url, env);
if (staticAsset) return staticAsset;
let routeData = void 0;
routeData = app.match(request);
if (!routeData) {
  const asset = await fallbackToAssets(request.url, env);
  if (asset) return asset;
}
const locals = createLocals(context);
const waitUntil = context.waitUntil.bind(context);
let response = await app.render(request, {
  routeData,
  locals,
  waitUntil,
  prerenderedErrorPageFetch: createErrorPageFetch(env),
  clientAddress: getClientAddress(request)
});
```

And its two helpers, from the same package:

*Verbatim excerpt.*

```js
function matchStaticAsset(manifest, requestUrl, env) {
  const { pathname } = new URL(requestUrl);
  if (manifest.assets.has(pathname)) {
    return env.ASSETS.fetch(requestUrl.replace(/\.html$/, &quot;&quot;));
  }
  return void 0;
}
async function fallbackToAssets(requestUrl, env) {
  const asset = await env.ASSETS.fetch(
    requestUrl.replace(/index.html$/, &quot;&quot;).replace(/\.html$/, &quot;&quot;)
  );
  if (asset.status !== 404) {
    return asset;
  }
  return void 0;
}
```

A built file, such as a hashed CSS file under `/_astro/`, is in `manifest.assets`, so `matchStaticAsset` returns it before `app.match` runs. A prerendered page such as `/about/` misses there, because the manifest holds `/about/index.html` and the comparison is exact. `app.match` misses too, because Astro leaves prerendered routes out of request-time matching, so `fallbackToAssets` returns the built HTML. Only an on-demand route, page or endpoint, reaches `app.render` with a route and runs its middleware.

![Sequence diagram of four requests with run_worker_first set to true. All four pass through the Worker and the adapter’s handle function. A built CSS file and a prerendered page are returned from the asset binding before Astro renders anything. The on-demand route and the path with no route both reach app.render, but only the on-demand route runs Astro middleware; the path with no route gets the prerendered 404 file.](/images/blog/worker-first-or-assets-first-routing-astro-on-cloudflare/four-requests.svg#wide,phone,fit &quot;Worker first, middleware last: with run_worker_first set to true, the Worker runs for every request, but Astro middleware runs only for the on-demand route. @astrojs/cloudflare 14.2.4, astro 7.2.10, with a prerendered 404 page, checked on 15 October 2026.&quot;)

Source: TEEI, drawn from the code of @astrojs/cloudflare 14.2.4 and astro 7.2.10, read on 15 October 2026.

The path with no route is the one that surprised us. It falls through both helpers, and the asset binding&apos;s 404 is only a routing signal: `fallbackToAssets` returns nothing, so `handle()` continues into `app.render` without a route. With our prerendered 404 page, Astro fetches the built `404.html` through `prerenderedErrorPageFetch`, keeps the 404 status and does not run middleware. With an on-demand 404 page, Astro would run middleware for it instead. That is what happened to our 14 image URLs: none had an Astro route, so the adapter returned the prerendered 404 page and our middleware&apos;s redirect map never ran.

Astro&apos;s [middleware guide](https://docs.astro.build/en/guides/middleware/#error-pages) makes error-page behaviour the adapter&apos;s choice. Middleware for prerendered pages runs at build time. In the configuration traced here, request-time middleware covers on-demand routes; the built files, prerendered pages and prerendered 404 response need an earlier place for request-time redirects or headers.

Headers split the same way. Cloudflare applies [`_headers`](https://developers.cloudflare.com/workers/static-assets/headers/) rules to static asset responses, not to &quot;responses generated by your Worker code&quot;. With assets first, a header that must be on every response needs `_headers` for files and the Worker for the rest. With Worker first, we can add headers in one place if we wrap every response from the entry, including adapter responses and early returns; a header added after the adapter&apos;s handler covers only what that handler answers. The reproduction in the appendix, which has no early returns, adds a marker header there, and it is on the prerendered page, the on-demand page and the 404 alike.

## Moving redirects into the Worker entry

At build time, we generate a Worker entry with the adapter, then wrap that output in a small entry of our own that runs before Astro sees the request. This is its `fetch`:

*Adapted for publication.* Selected branches show the sitemap and redirect handling before the adapter and the repair after it. Other branches, among them the byte-range video step, the code comments and the scheduled handler are omitted; the retained lines are unchanged.

```js
export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    const pathname = url.pathname;

    if (pathname === &apos;/sitemap.xml&apos;) {
      return Response.redirect(new URL(&apos;/sitemap-index.xml&apos;, url.origin).toString(), 308);
    }

    const redirectTarget = lookupRedirect(pathname);
    if (redirectTarget) {
      return Response.redirect(redirectKeepingQuery(redirectTarget, url), 301);
    }

    const res = await _astroHandler.fetch(request, env, ctx);
    return repairFallbackRedirect(res, url);
  },
};
```

`_astroHandler` is the entry the adapter built; `repairFallbackRedirect` handles a separate case on the way out. The sitemap branch shows why this needs Worker first: with assets first, an old `sitemap.xml` left in the build would be served as a file before the redirect could run.

`lookupRedirect` reads maps we generate at build time. We copy page redirects from middleware and share one module for old image redirects, so we use one source for each redirect in both layers. For retired prerendered pages, we can also generate a map from the build, behind a build switch that is off by default.

The same placement also covers two other cases we encountered. On 7 October 2026, two Ukrainian Skills Academy addresses served fallback pages that forwarded after two seconds, while a Spanish address returned 404. On 10 October we added a 301 for all three addresses to the Worker entry. On 12 October, Ahrefs listed 476 retired pages we still built as 200 responses with a meta refresh; a middleware redirect could not replace a prerendered response at request time. We did not measure lost visits. The separate build-side problem is covered in [Why we moved Astro redirects out of the build](/blog/why-we-moved-astro-redirects-out-of-the-build-en/).

Each change came with a test against the entry we generate, which checks that old addresses redirect before a stand-in for Astro is called and that other paths still reach it; the reproduction below exercises the adapter. We rely on the results recorded in the commits of 6, 10 and 12 October 2026 and did not rerun the tests for this article.

## Choose the routing order for the behaviour you need

**Every file becomes a Worker invocation.** With `true`, the Worker runs for each stylesheet, image and font, while Cloudflare&apos;s [pricing page](https://developers.cloudflare.com/workers/platform/pricing/) says &quot;Requests to static assets are free and unlimited&quot;. We have not measured what Worker-first routing costs us.

**The list form is not a drop-in middle way.** The [binding reference](https://developers.cloudflare.com/workers/static-assets/binding/) lets `run_worker_first` take route patterns, with `!` to exclude asset folders, and the [single-page application guide](https://developers.cloudflare.com/workers/static-assets/routing/single-page-application/) says the list &quot;disables the automatic `Sec-Fetch-Mode: navigate` detection&quot;. In the local Wrangler 4.107.0 code we read, with `&quot;404-page&quot;` and a built `404.html`, a missing path that matches no pattern gets the 404 page without the Worker running. The patterns would need to cover every built-page redirect and missing path the Worker must handle. We have not used the list form or run it in our reproduction.

**Use assets first when built files need no Worker behaviour.** It serves them without a Worker invocation. Test browser navigation to on-demand routes with the deployed compatibility date and flags; a plain curl request is not the same routing case.

**Development and production can route differently.** In the setup examined on 15 October 2026, we ran `astro dev` with assets first, because with Worker first the dev route matcher took over Vite&apos;s own module and source URLs. We tested redirects that needed Worker first against the wrapped entry; the assets-first dev setup could not exercise that routing order.

**Use Worker first when code must run before any asset answer.** An old file can otherwise win over a redirect for the same path. Put that redirect before the adapter; reserve Astro middleware for behaviour that belongs to rendering an on-demand route.

## Appendix: reproduce it in your own project

We wrote this project for the article on 15 October 2026; it contains nothing of TEEI&apos;s. Save each block in an empty folder under the name above it.

`./package.json`

*Verbatim excerpt.*

```json
{ &quot;name&quot;: &quot;repro-worker-first&quot;, &quot;private&quot;: true, &quot;type&quot;: &quot;module&quot;,
  &quot;scripts&quot;: { &quot;build&quot;: &quot;astro build&quot; },
  &quot;dependencies&quot;: { &quot;astro&quot;: &quot;7.2.10&quot;, &quot;@astrojs/cloudflare&quot;: &quot;14.2.4&quot;, &quot;wrangler&quot;: &quot;4.107.0&quot; } }
```

`./astro.config.mjs`

*Verbatim excerpt.*

```js
import { defineConfig } from &apos;astro/config&apos;;
import cloudflare from &apos;@astrojs/cloudflare&apos;;
export default defineConfig({ output: &apos;server&apos;, adapter: cloudflare() });
```

`./wrangler.jsonc`

*Verbatim excerpt.*

```jsonc
{
  &quot;name&quot;: &quot;repro-worker-first&quot;,
  &quot;main&quot;: &quot;./src/worker.ts&quot;,
  &quot;compatibility_date&quot;: &quot;2024-09-23&quot;,
  &quot;compatibility_flags&quot;: [&quot;nodejs_compat&quot;],
  &quot;assets&quot;: { &quot;binding&quot;: &quot;ASSETS&quot;, &quot;directory&quot;: &quot;./dist/client&quot;, &quot;run_worker_first&quot;: true, &quot;not_found_handling&quot;: &quot;404-page&quot; }
}
```

`./src/worker.ts`

*Verbatim excerpt.*

```ts
import { handle } from &apos;@astrojs/cloudflare/handler&apos;;
export default {
  async fetch(request: Request, env: any, ctx: any) {
    const res = await handle(request, env, ctx);
    const out = new Response(res.body, res);
    out.headers.set(&apos;x-worker&apos;, &apos;ran&apos;);
    return out;
  },
};
```

`./src/middleware.ts`

*Verbatim excerpt.*

```ts
import { defineMiddleware } from &apos;astro:middleware&apos;;
export const onRequest = defineMiddleware(async (_ctx, next) =&amp;gt; {
  const res = await next();
  const out = new Response(res.body, res);
  out.headers.set(&apos;x-middleware&apos;, &apos;ran&apos;);
  return out;
});
```

`./src/pages/static.astro`

*Verbatim excerpt.*

```astro
---
export const prerender = true;
---
&lt;h1&gt;prerendered&lt;/h1&gt;
```

`./src/pages/dynamic.astro`

*Verbatim excerpt.*

```astro
---
export const prerender = false;
---
&lt;h1&gt;on demand&lt;/h1&gt;
```

`./src/pages/404.astro`

*Verbatim excerpt.*

```astro
---
export const prerender = true;
---
&lt;h1&gt;not found&lt;/h1&gt;
```

The probe runs `wrangler dev` against the configuration the adapter generated, with `run_worker_first` and the compatibility date replaced, and prints the status and markers for each path and request type.

`./probe.sh`

*Adapted for publication.* We omit the comments. This version edits the generated configuration as JSON instead of with the `sed` line we ran on 15 October, and prints the values it reads back from the written file, so a formatting change cannot leave the old settings in place while the output names the new ones. It refuses to start if port 8797 already answers, waits until Wrangler reports that it is ready, and stops with the last lines of Wrangler&apos;s log if the server exits or is not ready within 60 seconds (`PROBE_WAIT` changes the limit). The first argument sets `run_worker_first`, the second the compatibility date.

The table above comes from the probe we recorded on 15 October 2026, not from this version. On 22 October 2026 we ran this version, byte for byte, against `wrangler dev` in a copy of the reproduction above, with Astro 7.2.10, @astrojs/cloudflare 14.2.4, Wrangler 4.107.0 and workerd 2026-10-12. All four cases printed the same six rows as the recorded probe. We also checked that it stops with an error and prints no measurement rows when the port is already taken, when the Worker cannot start and when the wait runs out.

```bash
#!/usr/bin/env bash
set -euo pipefail
RWF=&quot;${1:?true|false}&quot;; CD=&quot;${2:?compatibility_date}&quot;
HOST=127.0.0.1; PORT=8797; WAIT=&quot;${PROBE_WAIT:-60}&quot;
node --input-type=module - &quot;$RWF&quot; &quot;$CD&quot; &amp;lt;&amp;lt;&apos;NODE&apos;
import fs from &apos;node:fs&apos;;
const [workerFirst, date] = process.argv.slice(2);
if (![&apos;true&apos;, &apos;false&apos;].includes(workerFirst)) throw new Error(&apos;Expected true or false&apos;);
const config = JSON.parse(fs.readFileSync(&apos;dist/server/wrangler.json&apos;, &apos;utf8&apos;));
config.assets = { ...config.assets, run_worker_first: workerFirst === &apos;true&apos; };
config.compatibility_date = date;
fs.writeFileSync(&apos;dist/server/wrangler.probe.json&apos;, JSON.stringify(config, null, 2));
const written = JSON.parse(fs.readFileSync(&apos;dist/server/wrangler.probe.json&apos;, &apos;utf8&apos;));
if (written.assets.run_worker_first !== (workerFirst === &apos;true&apos;) || written.compatibility_date !== date) {
  throw new Error(&apos;wrangler.probe.json does not hold the requested values&apos;);
}
console.log(`run_worker_first=${written.assets.run_worker_first} compatibility_date=${written.compatibility_date}`);
NODE
if (exec 3&amp;lt;&amp;gt;&quot;/dev/tcp/$HOST/$PORT&quot;) 2&amp;gt;/dev/null; then
  echo &quot;Port $PORT already answers. Stop that server first, or the probe would measure it.&quot; &amp;gt;&amp;amp;2
  exit 1
fi
LOG=&quot;$(mktemp)&quot;
set -m
npx wrangler dev --config dist/server/wrangler.probe.json --port &quot;$PORT&quot; --ip &quot;$HOST&quot; &amp;lt; /dev/null &amp;gt; &quot;$LOG&quot; 2&amp;gt;&amp;amp;1 &amp;amp;
PID=$!
set +m
cleanup() {
  local status=$?
  kill -- &quot;-$PID&quot; 2&amp;gt;/dev/null || true
  for _ in 1 2 3 4 5 6 7 8 9 10; do kill -0 -- &quot;-$PID&quot; 2&amp;gt;/dev/null || break; sleep 1; done
  kill -KILL -- &quot;-$PID&quot; 2&amp;gt;/dev/null || true
  wait &quot;$PID&quot; 2&amp;gt;/dev/null || true
  [ &quot;$status&quot; -ne 0 ] || rm -f &quot;$LOG&quot;
}
trap cleanup EXIT
fail() { echo &quot;$1. Last lines of the Wrangler log ($LOG):&quot; &amp;gt;&amp;amp;2; tail -n 20 &quot;$LOG&quot; &amp;gt;&amp;amp;2; exit 1; }
for i in $(seq 1 &quot;$WAIT&quot;); do
  kill -0 &quot;$PID&quot; 2&amp;gt;/dev/null || fail &quot;wrangler dev exited before it was ready&quot;
  grep -F &apos;Ready on&apos; &quot;$LOG&quot; | grep -qF &quot;//$HOST:$PORT&quot; &amp;amp;&amp;amp; break
  [ &quot;$i&quot; -lt &quot;$WAIT&quot; ] || fail &quot;wrangler dev was not ready after $WAIT s&quot;
  sleep 1
done
curl -s -o /dev/null &quot;http://$HOST:$PORT/static/&quot; || fail &quot;wrangler dev reported ready, but port $PORT does not answer&quot;
for p in /static/ /dynamic /nope/; do
  for h in &quot;Sec-Fetch-Mode: navigate&quot; &quot;X-Plain: 1&quot;; do
    printf &apos;%-10s %-26s &apos; &quot;$p&quot; &quot;[$h]&quot;
    curl -sI -H &quot;$h&quot; &quot;http://$HOST:$PORT$p&quot; | tr -d &apos;\r&apos; | grep -iE &apos;^HTTP|^x-worker|^x-middleware&apos; | sed -E &apos;s#HTTP/1.1 ##&apos; | tr &apos;\n&apos; &apos; &apos; || fail &quot;No answer from $p&quot;
    echo
  done
done
kill -0 &quot;$PID&quot; 2&amp;gt;/dev/null || fail &quot;wrangler dev exited during the probe&quot;
```

Install and build, then run all four cases from the table:

```bash
npm install
npx astro build
bash probe.sh true 2024-09-23
bash probe.sh false 2024-09-23
bash probe.sh true 2025-04-02
bash probe.sh false 2025-04-02
```

The last probe leaves the browser-only failure configuration in place. After it exits, start `npx wrangler dev --config dist/server/wrangler.probe.json --port 8797 --ip 127.0.0.1` and open `/dynamic` on that port. Compare that navigation with plain curl and curl with `Sec-Fetch-Mode: navigate`. Expect a 200 with both markers for the plain request and a 404 with neither for the navigation request. Then change to Worker first and rerun: both request types should reach the on-demand route, while `/static/` and `/nope/` should still lack the middleware marker.

The site&apos;s generated configuration read on 15 October 2026 had the same asset settings and compatibility date as the reproduction, with the `nodejs_compat_v2` and `nodejs_als` flags instead of `nodejs_compat`.

## Continue in the Astro series

Return to [the homepage case](/blog/our-homepage-is-a-live-system-astro-cloudflare-workers-en/) to follow one response into a visible update. Continue with [pruning fallback redirect pages and answering their old URLs](/blog/why-we-moved-astro-redirects-out-of-the-build-en/) for the separate decisions about build output and redirects before the Worker.</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Astro</category><category>Cloudflare Workers</category><category>Workers Static Assets</category><category>Routing</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>What We Bypass with Cloudflare Service Bindings</title><link>https://theeducationalequalityinstitute.org/blog/worker-to-worker-communication-service-bindings-rpc-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/worker-to-worker-communication-service-bindings-rpc-en/</guid><description>We moved a failing call onto a service binding and it kept failing. Weeks later we found that our RPC calls could write through a maintenance pause.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>Evenhall has a maintenance switch that pauses writes while we work on its database. We checked it in the Worker&apos;s `fetch` handler. Until 10 October 2026, our two RPC entrypoints into Evenhall went around that check: in the words of the commit that closed the gap, they &quot;never pass the fetch check, so they wrote while&quot; the switch was on. We now check the switch inside every writing RPC method.

That is the difference this note is about. When we connect Workers through a fetch binding, we keep the receiving router and every check in it. When we use RPC, we call methods directly. And through either kind of [service binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/), we skip the public edge, so we use public URLs for health checks: there we test the route a visitor takes.

Our overview of [how TEEI runs its platforms on Cloudflare Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/) introduces these connections. Here we go through the three paths between our Workers, and through incidents between 2 September and 10 October 2026 that taught us what a binding changes and what it does not. We checked Cloudflare&apos;s documentation on 15 October 2026; our own figures carry their own dates.

**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.

## Three ways our Workers reach each other

**Public fetch.** We send an ordinary `fetch()` from the calling Worker to the other Worker&apos;s public hostname. The request goes out through Cloudflare&apos;s edge and can be answered there before it reaches the target&apos;s `fetch` handler. On the same zone this only works in some cases: a global fetch to a Worker served through a route or a `workers.dev` subdomain is refused with [error 1042](https://developers.cloudflare.com/workers/observability/errors/) unless the caller has the `global_fetch_strictly_public` compatibility flag, while a Worker on a [Custom Domain](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/#worker-to-worker-communication) accepts it.

**Fetch binding.** We declare a service binding without an entrypoint in the calling Worker and call `env.BINDING.fetch(request)`. Cloudflare&apos;s [Wrangler configuration reference](https://developers.cloudflare.com/workers/wrangler/configuration/) says that &quot;if you do not specify an entrypoint, the default export of the Worker will be used&quot;, so the request lands in the same `fetch` handler that public traffic reaches, with the same router and the same middleware. What it skips is the public edge: service bindings, in Cloudflare&apos;s words, &quot;allow one Worker to call into another, without going through a publicly-accessible URL&quot;.

**RPC binding.** We export a class that extends `WorkerEntrypoint` from the receiving Worker, name that class as the `entrypoint` of a binding in the calling Worker, and call its methods as if they were local: `await env.BINDING.someMethod(arg)`. Cloudflare&apos;s [service bindings page](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/) calls this form &quot;recommended for most use cases&quot;. The [RPC documentation](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/) adds that &quot;a new instance of the class is created every time the Worker is called&quot;. There is no `Request`, no status code and no `fetch` handler on this path.

![Diagram of three ways one Worker reaches another. A public fetch passes through Cloudflare’s edge, then the target’s fetch handler, then a route. A fetch binding skips the edge and enters the same fetch handler. An RPC binding skips both and calls a method on a WorkerEntrypoint class directly. A dashed frame around the fetch handler notes that a check placed there never sees an RPC call.](/images/blog/worker-to-worker-communication-service-bindings-rpc/three-ways.svg#wide,phone,fit &quot;With each path we skip a little more. Through a fetch binding we still run the target’s fetch handler; through RPC we do not.&quot;)

Source: TEEI, drawn from our platform code and Cloudflare’s Workers documentation, read on 15 October 2026.

From TEEI Language we reach the Mentorship Worker through a fetch binding and Evenhall through an RPC entrypoint. We expose a second Evenhall entrypoint to the admin our staff use to review company applications. We run Evenhall&apos;s scheduled jobs from a small scheduler Worker that reaches Evenhall through a fetch binding. From admin we reach TEEI Language and TEEI Mentorship in production with a public fetch, on purpose, and we come back to why.

## A guard in fetch never sees an RPC call

We enforced Evenhall&apos;s maintenance switch in the `fetch` handler, before handling HTTP writes, including those that arrive through a fetch binding. TEEI Language and our admin reach Evenhall through two RPC entrypoints, and an RPC call never enters the `fetch` handler. So while the switch paused HTTP writes, the RPC writes went through.

We moved the check into the methods. In every writing method, we check the switch first and return a `write_paused` result, the same word the HTTP refusal carries, before we open a database client. In the admin entrypoint, a small wrapper runs that check before each writing method. We leave the reading methods unchanged.

The lesson is broader than one switch. Anything you put in a Worker&apos;s `fetch` handler, whether authentication, rate limits, logging, a request ID or a maintenance mode, covers public traffic and fetch bindings, and nothing that arrives over RPC. If your guards live there, a fetch binding keeps them; with RPC, you move each check into the methods.

In the regression test, we call every writing method with the switch on and expect `write_paused`, with neither the domain operation nor the database client called; we check that the reading methods still run, and that writes go through with the switch off. We run that test in plain Node with Vitest, mock the `cloudflare:workers` module, and construct each entrypoint the way the runtime would, a fresh instance with its environment set. So we test the methods directly, not through a binding: the test does not exercise serialisation across the boundary or a real binding stub. Cloudflare&apos;s route for that is [`@cloudflare/vitest-plugin`](https://developers.cloudflare.com/workers/testing/vitest-integration/), which runs tests inside the Workers runtime.

![Two diagrams of Evenhall. Before 10 October 2026, a maintenance switch in the fetch handler stops HTTP writes, but an RPC call goes straight to a method and writes to the database while the switch is on. After the fix, every writing RPC method checks the switch itself and answers write_paused without touching the database, while reading methods are unchanged.](/images/blog/worker-to-worker-communication-service-bindings-rpc/guard-before-after.svg#wide,phone,fit &quot;We check HTTP writes in the fetch handler, including calls through fetch bindings. For RPC writes, we check inside each method.&quot;)

Source: TEEI, drawn from our platform code, read on 15 October 2026.

## TEEI Language to Mentorship: a fetch binding into the same router

TEEI Language and TEEI Mentorship are two products on one shared platform, deployed from [one codebase as two Workers](/blog/one-codebase-two-programmes-react-router-8-cloudflare-workers-en/). TEEI Language owns every cron schedule on the platform, so when a scheduled job has to run inside Mentorship, we dispatch it from the Language scheduler through a fetch binding. Since 13 October 2026 that includes [Skills Academy](/programmes/education/skills-academy/)&apos;s scheduled jobs, which the Mentorship Worker serves on a hostname of its own.

Both Workers run the same router, so the Mentorship Worker already has a route for the work. Through a fetch binding we reuse that route, its middleware, its HTTP status handling and its own authentication: one route table, and no second interface.

Two things about that `Request` are easy to get wrong. The URL you give a fetch binding does not choose the Worker; the binding does. But the target Worker still sees the URL, and we decide which programme the platform is serving from the hostname. So we build the URL from Mentorship&apos;s real public hostname, even though the request never travels to it. Before sending, we check that the URL&apos;s origin is the one we expect for the caller&apos;s environment. That check covers the URL only: which Worker receives the call is set by the binding in the deployed configuration.

We check the preconditions, start a deadline set for each job, and send with `redirect: &apos;manual&apos;`, so a 3xx arrives as a status instead of being followed. We never throw from the dispatch: in the code we read on 15 October 2026, we return seven named failure outcomes as data, and a missing binding is one of them. We accept a 2xx reply with a non-empty status string and retain the reported job outcome. We validate the reply&apos;s shape without establishing that the job succeeded. When no job outcome comes back, we log the failed dispatch at error level and capture it in Sentry. When the peer reports an outcome, even with a non-2xx status, we can log the job as complete on the calling side, and the receiving job reports its own failures.

Where a failure is caught decides its name. We read the body before we test the status, inside its own catch, so an abort while we read the body of a 2xx reply becomes an invalid response, not a timeout. We infer this from the code; we have not observed it at runtime.

An abort on our side does not tell us whether the work ran on the other side, and the deadline gives no exactly-once guarantee. In the receiving jobs, we coordinate repeated calls with claims, leases and compare-and-set updates, and for email delivery we also reuse idempotency keys and recorded provider acceptances. That is what lets us abort a slow call and leave the rest to the next tick.

There is no local fallback: we never run the Skills Academy work inside a Language Worker that lacks the binding. We would rather a missing binding fail loudly every tick than have the work run quietly in the wrong Worker.

## 2 September 2026: the binding was not the fix

Until 2 September 2026, the scheduled release of mentor waves, a job TEEI Language starts and the Mentorship Worker carries out, went from one Worker to the other over a public fetch. From 06:45 UTC that day it failed every 15 minutes, and the Mentorship Worker logged no arriving requests. Our first diagnostic commit, at 09:48 UTC, said: &quot;Neither end could name the cause, so the loop was unclosable.&quot;

At 10:12 we moved the call onto a service binding. The theory was the one our overview describes, a Worker&apos;s fetch to another Worker on the same zone. The release kept failing. At 11:05 we wrote down why we were stuck: &quot;workerd raises TypeError for several unrelated refusals, so the name stopped being informative exactly where it mattered.&quot; Error 1042 is one such refusal; a request option the runtime rejects is another, and the two need opposite fixes. The runtime states which in its message, and we had been throwing that message away to keep URLs out of our logs. From then on, we removed the URLs from the runtime&apos;s message and kept up to 200 characters of it.

At 11:20 we read the runtime&apos;s message:

*Adapted for publication.* We changed only the line breaks.

```text
Invalid redirect value, must be one of follow or manual (error won&apos;t be implemented since it does not
make sense at the edge; use manual and check the response status code).
```

The call had set `redirect: &apos;error&apos;`. On 2 September 2026, workerd threw that TypeError before the request was made, &quot;so nothing is sent: the peer logs nothing, the firewall logs nothing&quot;. The same option sat in a second call, the one that reconciles email delivery status with our email provider, which had never worked at all.

![Timeline of 2 September 2026 in UTC. From 06:45 a scheduled release fails every 15 minutes. At 09:48 we improve diagnostics. At 10:12 we move the call onto a service binding, marked as not the fix. At 11:05 we keep the runtime’s own error message. At 11:20 we identify the cause: redirect set to error is refused before the request is sent. Four hours and 35 minutes passed from the first failure to the commit that named the cause.](/images/blog/worker-to-worker-communication-service-bindings-rpc/release-timeline.svg#wide,phone,fit &quot;The binding was the second of four commits. We kept the runtime’s own message at the third commit and identified the cause at the fourth.&quot;)

Source: TEEI, drawn from our commits of 2 September 2026, read on 15 October 2026.

We have not reconstructed when the first successful release followed, so we give no recovery time.

In the scheduled dispatch to Mentorship, we now set `redirect: &apos;manual&apos;` and treat a 3xx as an answer. On 15 October 2026 Cloudflare&apos;s [Request reference](https://developers.cloudflare.com/workers/runtime-apis/request/) still listed `error` among the modes, and we have not re-run the refusal against today&apos;s workerd, so check the version you deploy. The same page gives a reason to avoid the default as well: with `follow`, &quot;all headers will be forwarded to the redirect destination, even if the destination is a different hostname or domain&quot;, including `Authorization`. In a test, we walk our application and Worker sources for TypeScript files and fail on the literal `redirect: &apos;error&apos;`. The test also requires the scan to have covered more than 200 files, so a moved directory cannot make it pass by reading nothing; plain JavaScript modules the Worker imports fall outside it.

We kept the binding. It was not the fix, but we documented in TEEI Language&apos;s configuration that the call enters the Mentorship Worker directly rather than through the public hostname, and the same binding has carried the Skills Academy jobs since 13 October.

## TEEI Language to Evenhall: a named entrypoint

[Evenhall](/partners/evenhall/) is where CSR, HR and sustainability leads at partner companies see what their employees did through TEEI&apos;s programmes. From TEEI Language&apos;s scheduler, we reconcile eligible employee-registration and completed-activity milestones for both programmes into Evenhall.

This call is RPC. We export `CsrApplicationActivation` from Evenhall as a class extending `WorkerEntrypoint`, and bind TEEI Language to that named entrypoint:

*Adapted for publication.* We replaced the two service names with placeholders and removed a third binding and its comment.

```jsonc
&quot;services&quot;: [
  { &quot;binding&quot;: &quot;MENTORSHIP_SCHEDULED_WAVE_SERVICE&quot;, &quot;service&quot;: &quot;&quot; },
  {
    &quot;binding&quot;: &quot;CSR_APPLICATION_ACTIVATION&quot;,
    &quot;service&quot;: &quot;&quot;,
    &quot;entrypoint&quot;: &quot;CsrApplicationActivation&quot;
  }
]
```

The two Workers live in separate repositories, and TEEI Language does not import Evenhall&apos;s types. We documented the reason in a comment above the copied types: &quot;Keep this Worker independently buildable; source-domain imports across repositories are forbidden.&quot; So we copy the contract as types:

*Verbatim excerpt.*

```ts
type RpcResult = { ok: true; data: T } | { ok: false; code: string; message: string };
export interface CsrActivationBinding {
  targets(
    cursor: string
  ): Promise&amp;gt;;
  record(event: CsrActivationEvent): Promise&amp;gt;;
}
```

We check that `targets` and `record` are functions, not that replies match our copied types. With `{ ok: true, data: { targets: null, nextCursor: null } }`, the loop would fail before `record()`. If both Workers build together, typecheck them against one shared contract.

We keep the cursor in TEEI Language because we get a fresh entrypoint instance on every call. In a loop, we ask Evenhall for a page of companies with outstanding milestones, establish from TEEI Language&apos;s own records which milestones to record, and call `record()` for each one. We page by a stable identifier rather than a row offset, so targets that drop off the list once complete do not shift later rows past the cursor. The cursor is a position in that order, not a snapshot: a target that appears behind it waits for the next run, which starts from an empty cursor. In Evenhall we derive `nextCursor` from the full database page, independently of the targets we return, so an empty target page can still have a next page; a cursor that does not move forward stops the loop with an error.

![Sequence diagram of TEEI Language and Evenhall. In a loop, TEEI Language asks Evenhall for a page of targets with a cursor, finds each target’s eligible milestones in its own records for both programmes, and asks Evenhall to record each one. Every answer is either ok with data or not ok with a code. A not-ok answer or a cursor that does not move forward stops the loop with a named error. A company leaves the target list once both first milestones are recorded, and we deduplicate identical replayed events.](/images/blog/worker-to-worker-communication-service-bindings-rpc/cursor-loop.svg#wide,phone,fit &quot;We establish the milestones from TEEI Language’s own records and deduplicate them in Evenhall.&quot;)

Source: TEEI, drawn from our platform code, read on 15 October 2026.

A crash halfway through the loop is safe to repeat, because we deduplicate recorded milestones durably in Evenhall. We accept an identical replay as `recorded: false` and refuse a replay that disagrees with the stored event, and we write the event and the milestone update in one transaction, keeping the earliest time for each milestone. We retry only acquiring that transaction. Once our statements have run, we do not replay an ambiguous commit or network failure as a fresh transaction; a later run relies on the deduplication instead.

Every method on the entrypoint returns `{ ok: true, data }` or `{ ok: false, code, message }` and does not throw across the binding. So we receive refusals as result data and failed calls as exceptions. In TEEI Language, a returned refusal becomes one fixed local error for `targets()` and another for `record()`, while a rejected call propagates as its own exception, so we keep which operation failed but discard Evenhall&apos;s refusal code.

Returning codes as data also sidesteps a detail of RPC errors. An error thrown on the far side &quot;propagates to the caller as a new error object&quot;, and Cloudflare&apos;s [error handling page](https://developers.cloudflare.com/workers/runtime-apis/rpc/error-handling/) says that without the `enhanced_error_serialization` flag, RPC &quot;does not preserve custom own properties&quot;. The flag is on by default only for compatibility dates on or after 2026-04-21. TEEI Language and TEEI Mentorship run on compatibility date 2024-11-01 and Evenhall on 2026-04-01, and none of the three sets the flag. By the documentation, a custom `code` property on an error thrown in Evenhall would not reach TEEI Language; we have not tested that.

## The binding was in the file, not in the Worker

We run Evenhall&apos;s scheduled jobs in a small Worker of their own and connect it to Evenhall through a fetch binding. We documented the reason in its configuration: &quot;Dispatch uses a service binding because a global fetch to another same-zone Worker fails with 1042&quot;.

On 6 September 2026 we read that Worker&apos;s record in our Cloudflare account. In a six-day window, it had run 29 times and thrown 27 times, each time after exactly one subrequest, and Evenhall had received none of its calls. Our runbook gives the cause: &quot;The declared transport is a service binding, and the deployed Worker has none&quot;. We had committed the binding on 5 September 2026, and the copy running in production had last been deployed on 12 July.

A binding is part of a Worker&apos;s deployed configuration, and committing it changes nothing until that Worker is deployed again. We redeployed the scheduler to production on 17 September 2026. When we measured again on 11 October, all 40 of its scheduled runs from 4 to 10 October had status `success`. In our runbook, `success` means the handler did not throw; it does not prove that every call it made was answered with a 2xx.

[How We Compare Builds and Verify a Worker Release](/blog/deploying-the-exact-worker-you-tested-en/) covers how we tie a deploy to what was built. For bindings the rule is simpler: after changing one, look at the deployed Worker&apos;s bindings, not at the file.

## Binding in staging, public fetch in production

We send governance actions from our admin Worker to TEEI Language and TEEI Mentorship. In production we call both programmes through a public fetch, and we wrote the reason into the configuration: &quot;Production has no binding on purpose: its origins are custom domains and answer.&quot; A Custom Domain target is the case the [custom domains page](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/#worker-to-worker-communication) exempts from the same-zone rule.

In staging, a plain fetch from the admin Worker to TEEI Language was answered by something in between, a 403 HTML page, and the TEEI Language Worker&apos;s log saw no request at all. A binding calls the Worker directly, so on 28 September 2026 we gave admin a binding to each programme in staging only, and the same code takes whichever path the environment has:

*Verbatim excerpt.*

```ts
const send = binding ? binding.fetch.bind(binding) : fetch;
```

For non-2xx replies without the receiving route&apos;s expected `outcome` or `error` field, we log which path the call took and report the reply as a refusal from something other than TEEI Language. That is a check of the reply&apos;s shape: it does not establish which component produced the response. Every call has a deadline, and beside the timeout handling we wrote down what a timeout means on any of these paths: &quot;A timeout is genuinely ambiguous: the operation may have landed. The operation id is what makes retrying safe, so the screen tells the operator to retry rather than to redo.&quot;

## Why our monitoring takes the public road

A binding goes around the public edge. That is its advantage for scheduled work, and the reason we never use one to check health. A green binding call proves that one Worker can reach another inside our account. It can stay green while a certificate, DNS or zone problem breaks the route a learner takes.

We use a separate monitor Worker to run health checks through both programmes&apos; public hostnames, with manual redirects and a deadline, so we can keep monitoring during an application outage. After every Mentorship deploy, we run a probe that reads each public hostname that Worker serves, including Skills Academy&apos;s, and checks that each one reports the same source revision as the canonical host. It compares reported revisions, not the deployed artefacts. We wrote the argument for it at the top of the probe on 13 October 2026: &quot;Until this module, every post-deploy check read the canonical host only, so the second address could stop answering, or answer from another Worker, with every gate green.&quot;

For the website we made the same choice for the same reason, as our overview describes: we call another of our Workers through the `global_fetch_strictly_public` flag, not a binding, so we exercise the public route.

## What a binding costs, and what we have not measured

Cloudflare&apos;s [pricing page](https://developers.cloudflare.com/workers/platform/pricing/) is short on this: &quot;Requests made from your Worker to another worker via a Service Binding do not incur additional request fees.&quot; A request that crosses a binding is billed as one request for the first Worker and &quot;the total amount of CPU time used across both Worker A and Worker B&quot;.

No additional request fee does not mean nothing is counted. Each call through a binding counts towards the caller&apos;s subrequest limit, which the [limits page](https://developers.cloudflare.com/workers/platform/limits/) sets at a default of 10,000 per invocation on Workers Paid, configurable up to 10 million. Cloudflare&apos;s [service bindings page](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/) allows at most 32 Worker invocations per request, each binding call included, while the [errors page](https://developers.cloudflare.com/workers/observability/errors/) describes a guard against a Worker calling itself or another Worker more than 16 times, ending in error 1019; neither page says whether the two are one mechanism. A binding call does not count towards the limit on connections an invocation can hold open at once. In a write run of our Evenhall loop, we make one `targets()` call per page and one `record()` call per eligible event, also when the answer is `recorded: false`, so call volume and chain depth are different questions. Test sequential calls across one binding separately from a nested chain, and do not infer either limit from the other.

We have no figures for the latency or cost of a binding call against a public call, the number of binding calls per day, how often a binding call fails in production, or how many scheduled dispatches time out. We cannot isolate binding calls from the subrequest ratios in our overview, because they also count database and external calls. If you need those numbers, add a structured log line for each cross-Worker call with its outcome and its transport, configure invocation logging and sampling, and account for sampling before you derive totals.

## Verify this

- **Reproduce the bypass in your own account.** Deploy two Workers. In B, export a class that extends `WorkerEntrypoint` with one writing method, and give B a `fetch` handler that refuses POST requests while an environment variable is set. In A, declare one binding with `&quot;entrypoint&quot;` set to your class and one without. Set the variable and call both: the fetch binding is refused, the RPC write goes through. Then move the check into the method. Test the method directly for its behaviour, and separately inside the Workers runtime with `@cloudflare/vitest-plugin`, through a configured binding.
- **Read the bindings of the deployed Worker.** After adding or changing a binding, compare the calling Worker&apos;s deployed configuration with the file, and make one real call through the binding before you rely on it.
- **Test the catch boundary.** In a fixture, reject `fetch()` with `AbortError`, then return a 2xx response whose body rejects with the same error. Expect a timeout first and an invalid response second.
- **Repeat a write after an ambiguous outcome.** In a fixture, let a write commit and then lose the acknowledgement before it reaches the caller. Restart from an empty cursor and assert that the committed event is not created again, while outstanding milestones can still be recorded. Separately, fail the milestone update before commit and assert that neither change persists.

The incident reports, scheduler measurements and test details come from internal documentation that readers cannot independently inspect.

If you are splitting a system across Workers and want to compare notes, [talk to us](/partners/technology/).</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Service Bindings</category><category>RPC</category><category>Cloudflare Workers</category><category>WorkerEntrypoint</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>Counting to 128 Before We Add One Worker Secret</title><link>https://theeducationalequalityinstitute.org/blog/working-within-worker-configuration-and-bundle-budgets-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/working-within-worker-configuration-and-bundle-budgets-en/</guid><description>We count built variables before a deploy and deployed variables before a secret write, adding the live secret inventory in both cases.</description><pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate><content:encoded>On 27 September 2026 we could not add one API key to production TEEI Language. `wrangler secret put` came back with error 10055: &quot;Too many text bindings, found a total of 129, they exceed the limit of 128&quot;. The Worker already carried 34 plain-text variables and 94 secrets, the full allowance of 128, and nothing in our build or tests had read what was live on it.

We now count before writing. The inputs depend on the write: the built configuration&apos;s variables before a code deploy, the deployed version&apos;s variables before a standalone secret write, and in both cases the live secret names plus any new ones. If we remove a variable from a candidate, we free no slot until we deploy that candidate.

We build TEEI Language and TEEI Mentorship from [one React Router 8 codebase and deploy them as two Workers](/blog/one-codebase-two-programmes-react-router-8-cloudflare-workers-en/). Each Worker has its own configuration, so each has its own count against the 128. [How TEEI runs its platforms on Cloudflare Workers](/blog/how-teei-runs-its-platforms-on-cloudflare-workers-en/) covers the other limits we met in 2026.

**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 Cloudflare counts, and when

As of 15 October 2026, Cloudflare&apos;s [limits page](https://developers.cloudflare.com/workers/platform/limits/) lists &quot;Variables per Worker (secrets + text)&quot; as 64 on Workers Free and 128 on Workers Paid, with each variable at most 5 KB. The two kinds share one count because, in the words of the [secrets documentation](https://developers.cloudflare.com/workers/configuration/secrets/), &quot;Secrets are environment variables&quot;. Crossing the limit is [error 10055](https://developers.cloudflare.com/workers/observability/errors/).

- **It is checked at the write, not at the build.** We do not see the live secret inventory in a build or a test unless we fetch it. Before we added our count, a change that added one variable could pass every local check and still exceed the combined limit at upload.
- **`wrangler secret put` deploys immediately.** Per the documentation, it &quot;creates a new version of the Worker and deploys it immediately&quot;. Both refusals we have on record came from secret writes, not from code deploys: the `secret put` on 27 September, and a `wrangler secret bulk` on 12 October.
- **A deploy keeps the secrets and replaces the variables.** The [Wrangler command reference](https://developers.cloudflare.com/workers/wrangler/commands/workers/) says &quot;Secrets are never deleted by a deployment&quot;, and that Wrangler overrides variables changed in the dashboard at the next deploy unless `keep-vars` is set. Before a code deploy, we count built variables and live secret names. In our React Router 8 build we choose the Wrangler environment before compiling, and the build flattens that choice into the upload artefact, so we read the variables from that artefact.

![Two sequences. Without a count, nothing between the build and the secret write reads the live secrets, and Cloudflare refuses the write with error 10055. With the count, we compare live secrets and planned additions with the variables that apply to the write: built variables before a code deploy, deployed variables before a standalone secret write. We stop on OVER or INCONCLUSIVE.](/images/blog/working-within-worker-configuration-and-bundle-budgets/where-the-limit-is-checked.svg#wide,phone,fit &quot;We added the count on 27 September 2026. The left lane illustrates where a local build can miss the live inventory; it is not a recorded build and test run. The right lane is the check we run before a write.&quot;)

Source: TEEI, drawn from our platform code on 16 October 2026.

## One function that counts first

We fixed 27 September in two halves. With approval, we deleted two secrets that the Worker code did not read, set the key, and stood at 34 variables and 93 secrets, 127 of 128. Then we wrote the count down as code, so that the next person asks before writing instead of finding out from Cloudflare.

*Adapted for publication.*

```js
export const TEXT_BINDING_LIMIT = 128;

/** Below this many free slots the verdict is LOW. */
export const HEADROOM = 3;

/**
 * @param {{ vars: string[], secretNames: string[], plannedSecrets?: string[], limit?: number, headroom?: number }} input
 *   `plannedSecrets` are secrets about to be put; one already on the Worker is not counted twice.
 */
export function bindingBudget({ vars, secretNames, plannedSecrets = [], limit = TEXT_BINDING_LIMIT, headroom = HEADROOM }) {
  const varNames = new Set(vars);
  const secrets = new Set(secretNames);
  const added = plannedSecrets.filter((name) =&amp;gt; !secrets.has(name));
  for (const name of added) secrets.add(name);
  // A name that is both a var and a secret cannot be uploaded at all, so it is reported by name
  // rather than folded into the arithmetic.
  const collisions = [...secrets].filter((name) =&amp;gt; varNames.has(name)).sort();
  const total = varNames.size + secrets.size;
  const free = limit - total;
  const verdict = collisions.length &amp;gt; 0 || total &amp;gt; limit ? &apos;OVER&apos; : free &amp;lt; headroom ? &apos;LOW&apos; : &apos;PASS&apos;;
  return { vars: varNames.size, secrets: secrets.size, added, collisions, total, limit, free, verdict };
}
```

Three details carry most of the value. Sets, not arrays, so a planned secret that is already on the Worker is an update and costs nothing. A name that is both a variable and a secret is reported as OVER whatever the count, because it cannot be uploaded at all. And exactly 128 is LOW, not OVER: it uploads, and the next binding does not. LOW covers zero, one and two free slots, so it is a warning, not a promise that one more change will fail.

Before a code deploy, we read the target&apos;s built variables and live secret names, never secret values, and we can pass a planned secret to ask &quot;can I set this?&quot;. The answer is only as good as the variables we count. A standalone `secret put` or `secret bulk` uploads no variables, so before one of those the variables that count are the ones in the deployed version. On 12 October our Language Worker carried 31 variables while the candidate we were preparing carried 30; until we deployed the candidate, the variable it dropped freed nothing.

The count also reserves nothing. It records the inventory at the moment we read it, another write can take a slot before ours, and Cloudflare still makes the final decision. We made the command exit 0 for PASS or LOW and 1 for OVER. When we cannot read the live names it exits 3, INCONCLUSIVE, and we wrote down plainly what that means: nothing measured, never a pass.

In the unit tests, we replay the refusal: 34 variables, 94 secrets and one planned key give 129, OVER. In our live check on 27 September 2026, we counted production Language at 127 of 128, LOW, and Mentorship at 85, PASS; two planned secrets gave 129 and OVER, and no credentials gave INCONCLUSIVE.

## The gates in front of Cloudflare&apos;s

We do not rely on someone remembering to run the script.

**A ceiling in a test.** We cannot read secrets from a file, so in a contract test we cap the production Language variables and check the cap against the secret count we measured on the same day.

*Adapted for publication.*

```js
const PRODUCTION_VAR_CEILING = 25;
const SECRETS_MEASURED_2026_10_12 = 97;

describe(&apos;production Language vars against the 128 bindings&apos;, () =&amp;gt; {
  it(&apos;declares no more vars than the ceiling&apos;, () =&amp;gt; {
    expect(Object.keys(LANGUAGE.vars).length).toBeLessThanOrEqual(PRODUCTION_VAR_CEILING);
  });

  it(&apos;keeps the ceiling itself under Cloudflare\&apos;s limit beside the secrets measured with it&apos;, () =&amp;gt; {
    expect(PRODUCTION_VAR_CEILING + SECRETS_MEASURED_2026_10_12).toBeLessThan(TEXT_BINDING_LIMIT);
  });
});
```

Three more tests in the same file fail if one of seven database-backed flags, a host name or one other setting comes back into the production variables. We log each ceiling change with its date and measurement, and the rule next to the constant is that raising it is a decision taken after reading the live total, never a fix to make a red test green. The test has a limit of its own: the 97 is a number we typed in. It matched the live count on 15 October; we cannot detect later secret-count changes through this test. For that we run the script.

**A test that pins defaults.** When we remove a variable because the code that reads it already falls back to the same value, we test that each reader&apos;s default matches the value the configuration used to declare, across the relevant blocks of all three Worker configurations. If someone changes a reader&apos;s default later, we catch it in the test.

**The break-glass lane.** In our release lane for urgent fixes, we run the same count against the built configuration of every target and refuse to mark a candidate ready on OVER or INCONCLUSIVE.

## Four ways to free a slot

Asking for more room was never on the table, because 128 is the Paid limit. Every slot we needed after 27 September came from the same Worker.

1. **Move a flag that is off to the database-backed flag store.** A Wrangler variable lives in the Worker version, so changing it means a deploy. A row in a flag table can be changed without one and costs no binding. Our normal order: the catalogue that names the flag as a database flag ships first, then we write a row per programme with today&apos;s value, and only the release after that removes the variable. For three flags that were off on 27 September we skipped the rows, because their readers treat a missing value as off. Turning a database flag on still goes through the same approval as it did when it was a variable.
2. **Delete a secret nothing reads.** That gave us the two slots on 27 September. By 14 October that well was dry: we checked, and none of the 97 live secrets was unread.
3. **Let the reader carry the default.** On 12 and 13 October an analytics host and a log level left the production variables, because the code that reads them already defaulted to exactly the declared values. The defaults test pins that.
4. **Fold related values into one JSON secret.** On 13 October we changed the code that configures our story-card renderer to read several related settings from one secret holding a JSON object, so on Language they need one binding instead of several. Writing that secret on Language was among the planned slots, not a measured change.

The fold needs one rule. We treat a missing or blank folded value as absent, and only then does the renderer look for the old separate settings. A non-empty value we cannot parse turns rendering off instead of falling back, so a broken secret cannot quietly run the renderer on stale values. The cost: one typo in that secret takes the renderer, and everything that relies only on that value, dark.

The flag store needs two guards to keep it from becoming a second way to break things. We take a value from the table only for a key that the bundled flag catalogue lists as a database flag, and the staff page refuses to write any other. And the three switches that decide whether our scheduled jobs run never come from the database, because, as the module puts it, &quot;a database that is down must not be able to change whether the crons run&quot;. The rows also have a scope. We lay them over the Worker&apos;s environment for application requests that reach the database-backed application and for scheduled jobs dispatched through our application router.

## Three weeks on production Language

Figure 2 shows the free slots at the points we measured: none at the refusal on 27 September, one after the fix, and none again on 7 and 12 October. On 12 October a `secret bulk` for search settings was refused with 10055 and nothing was written; that evening we removed three variables from the production configuration.

![Free slots on production TEEI Language: zero on 27 September, one after two unread secrets were deleted, zero again on 7 and 12 October, six on 15 October 2026 counted from the committed configuration and the live secrets.](/images/blog/working-within-worker-configuration-and-bundle-budgets/free-slots.svg#wide,phone,fit &quot;Free slots against the limit of 128 on production TEEI Language, 27 September to 15 October 2026. The open diamond combines committed configuration variables with live secret names; it does not measure the variables in the deployed version.&quot;)

Source: TEEI, from the binding counts recorded in our release commits and a count of live secret names on 15 October 2026, read on 15 October 2026.

On 15 October 2026 we counted 25 variables in the committed production Language configuration and 97 secret names live from Cloudflare, 122 of 128; Mentorship gave 26 and 72, 98 of 128, by the same method. These are committed configurations plus live secrets, not inventories read from the deployed versions, and we did not count the staging, preview and demo Workers. Several of our commits from 12 to 14 October describe Language as &quot;128 of 128&quot;; some of those totals included planned additions rather than settings already written, so they are not measurements of the deployed inventory.

## Trade-offs

**Database flags cost a read.** Each isolate caches the flag rows briefly and refreshes them only when a caller asks; where the request allows, we serve the expired snapshot at once and refresh behind the response. So a changed flag has no guaranteed time to reach every isolate. If you need a flag to change everywhere within a known time, a variable and a deploy is the more predictable tool.

We test a shared read that never settles: we remove its creator&apos;s timers, then start a second caller, and require that caller to return no flag rows at its own deadline, with one database SELECT in total.

**A lower ceiling adds friction.** Every time we lowered the ceiling in the test, we removed the option of &quot;just add a variable&quot; for the next person. That is the point, and it is also friction: work that needs a binding has to retire one first.

**When this is the wrong pattern.** For a Worker far below its binding limit, running the count before writes may be enough. We use ceilings and change logs because production Language repeatedly reached the binding limit between 27 September and 12 October 2026 while several people were adding to it.

## Verify this

For a code deploy, count variables in the selected environment&apos;s `vars` block, or the generated configuration if your framework builds one. For a standalone `wrangler secret put` or `secret bulk`, use the deployed version&apos;s variables. Run `npx wrangler secret list` for that same Worker and environment. Merge its secret names with those you plan to write, counting existing names once; an update adds no slot. Add that count to the variables and compare with 128 (64 on Free). If a name is both a variable and a secret, resolve the collision even when the total fits.

Our own counts for TEEI&apos;s Workers come from internal measurements and configuration, so you cannot check them from outside; we have given each one with its method and date.</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Cloudflare Workers</category><category>Wrangler</category><category>Secrets</category><category>Feature flags</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>What TEEI Runs on AWS</title><link>https://theeducationalequalityinstitute.org/blog/everything-teei-runs-on-aws-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/everything-teei-runs-on-aws-en/</guid><description>The AWS services that carry TEEI&apos;s free programmes, measured: Chime video, Bedrock checks and who decides, monitoring, and how releases are verified.</description><pubDate>Wed, 14 Oct 2026 00:00:00 GMT</pubDate><content:encoded>*Live video, attendance, AI checks, monitoring and builds.
Measured on the live account on 14 and 15 October 2026.*

TEEI&apos;s free programmes connect people from Ukraine, and women from Latin America and the Caribbean,
with volunteers who help them practise a language and build a career. The Educational Equality
Institute runs those programmes on its own platform: TEEI Language, then Skills Academy, then TEEI
Mentorship, with Evenhall by TEEI as the workspace for partner companies. The web front ends run on
Cloudflare. Much of the work behind them runs on Amazon Web Services.

This article goes through the AWS services that carry that work and what each is for, how much each
was used over 30 days in September and October 2026, every AI check that runs on Amazon Bedrock and
who makes the final decision after it, and how the build lane on AWS CodeBuild worked and why it was
switched off. Every number was read from the live account or from the infrastructure code on 14 and
15 October 2026. Rounded figures say &quot;about&quot;.

**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.

## How it fits together

A person opens TEEI Language on a phone. The page comes from Cloudflare. When they join a session,
the Worker authenticates the request and asks AWS for a meeting; from then on their voice goes
through the Chime media service in Ireland. When they leave, Chime tells EventBridge in Frankfurt, a
forwarder tells the platform, and the platform settles the hours that a volunteer&apos;s record and a
partner company&apos;s report are later built from. If they chat, a model on Bedrock flags concerns for a
person to review. Outside the session, monitors on AWS watch the Cloudflare side every few minutes,
findings land as tickets on the team&apos;s board, and the code behind all of it is verified on one EC2
build machine before it ships.

![Three phone screens in a row, joined by arrows. First, the home screen of TEEI Language for a sample learner, with a goal, sample volunteers and the next session, tomorrow at 15:00. Second, a one-to-one practice session on a phone between demo personas, with the volunteer&apos;s video in the middle and the controls below. Third, Evenhall&apos;s demo with sample data: a company&apos;s quarterly impact report with 154 hours contributed, 10 volunteers and 27 sessions. Under each phone, the step and where it runs: the page and the booking on Cloudflare Workers; an authenticated request through Amazon API Gateway and the media through Amazon Chime SDK in Ireland; meeting events through Amazon EventBridge in Frankfurt, settled into the hours a company report is built from.](/images/blog/teei-aws/journey-hour.svg#wide &quot;One hour of practice, from the booking to the company report, and where AWS comes in.&quot;)

Source: TEEI, captured from the public tour of TEEI Language and Evenhall&apos;s public demo on 15 October 2026, and from a live Amazon Chime SDK meeting with demo personas on 14 October 2026. All data shown is sample data.

All of that exists for conversations like the ones Oksana K. describes.

![Oksana K., a TEEI Language participant](https://r2.theeducationalequalityinstitute.org/videos/cockpit/square/oksana-k-1.mp4?v=2026-09-22b#square &quot;Oksana K., a TEEI Language participant, on real conversations with real people, at the times and on the days that suit her.&quot;)

Source: TEEI, a film recorded with Oksana K.&apos;s consent; this square cut rendered on 22 September 2026.

One of TEEI&apos;s volunteers works at AWS. In a career film for TEEI Mentorship, Dana A. tells people
starting out in software how she got there.

![Dana A., a TEEI volunteer who works at AWS](https://r2.theeducationalequalityinstitute.org/videos/cockpit/square/dana-a-1.mp4?v=2026-09-22b#square &quot;Dana A., a TEEI volunteer who works at AWS, on teaching herself to code, and on starting projects and applying for jobs before you feel ready.&quot;)

Source: TEEI, a film recorded with Dana A.&apos;s consent; this square cut rendered on 22 September 2026.

## At a glance

Table: TEEI on AWS in figures. Read from the live account on 14 October 2026; the note says what each figure covers.

| Measure | Figure | Note |
|---|---|---|
| Account | | |
| AWS accounts | 1 | every Region below sits in the same account |
| Regions in use | 4 | Ireland for almost everything; Frankfurt for video control, attendance and an off-Region backup; London for a video test environment; Oregon for device testing only |
| CloudFormation stacks | 46 | written in AWS CDK (TypeScript) |
| Compute | | |
| AWS Lambda functions | 82 | 64 in Ireland, 10 in Frankfurt, 8 in London |
| Lambda invocations | 288,872 | in the 30 days to 14 October |
| Live video and captions | | |
| Amazon Chime SDK attendee-minutes | 142,536 | 1 July to 13 October 2026 |
| Amazon Transcribe streaming audio | 27,074 s | the same period, about seven and a half hours of captions |
| AI | | |
| Models in product features | 2 | Claude Sonnet 4.6 and Claude Haiku 4.5 on Amazon Bedrock, through the EU cross-Region inference profile |
| AI checks live for participants | 5 | each with a named final decision maker, below |
| Amazon Bedrock Guardrails checks | 72,427 | in 30 days, input and output |
| Monitoring and builds | | |
| Amazon CloudWatch alarms | 176 | with 100 log groups and one dashboard |
| AWS CodeBuild builds | 9,751 | all on record; the lane was switched off on 11 October 2026 |

## How it is put together

**One account, three working Regions.** TEEI uses a single AWS account. Ireland holds the API, the
functions, the tables, the AI calls, the monitoring and the build machine. Frankfurt is where the
Amazon Chime SDK control plane runs for TEEI&apos;s meetings and where meeting events arrive, so
attendance processing, an independent evidence trail and an off-Region copy of the database backups
sit there. The audio and video themselves flow through the Chime media service in Ireland. London
holds a separate video test environment, a complete second copy of the video path built for
end-to-end testing. Oregon holds no deployed resources: AWS Device Farm there gives remote access
to real iPhones for testing.

![Diagram of TEEI&apos;s one AWS account. Browsers and phones send audio and video directly to the Amazon Chime SDK media service in Europe (Ireland). TEEI&apos;s platforms on Cloudflare Workers, which hold no AWS keys, reach AWS through one signed door, Amazon API Gateway in Ireland, and Europe (Frankfurt) sends meeting events and watch results back to them. Ireland holds almost everything, from Amazon Bedrock and 64 Lambda functions to 159 alarms and the build machine. Frankfurt holds meeting control, meeting events, 10 Lambda functions and the backup copy. London holds a video test environment, and Oregon nothing deployed. Turso, Cloudflare Access, GitLab CI and ElevenLabs sit outside AWS.](/images/blog/teei-aws/account-map.svg &quot;One account, four Regions: almost everything in Ireland, meetings and events in Frankfurt, a video test environment in London, and only device testing in Oregon.&quot;)

Source: TEEI, drawn from our AWS infrastructure code and a read-only sweep of our AWS account, 14 and 15 October 2026.

**Everything is code.** The whole estate is one AWS CDK project in TypeScript. Six stacks are built
on every deploy: the foundation (encryption key, buckets, budgets, the alert topic), the data
stack (DynamoDB tables, an event bus, the analytics store, the database export bucket), the launch
stack (the API and nearly every product function), the alarm stack, and two attendance stacks in
Frankfurt. Every other stack, from monitoring to build lanes to database hosts, needs an explicit
flag at deploy time, so a plain deploy cannot bring anything up by accident. Four small CDK apps
sit beside the main one: the video test plane and evidence trail, two PDF renderers, and the
security sources. 46 stacks exist today, 40 in Ireland, 4 in Frankfurt and 2 in London.

**One signed door.** TEEI Language, Skills Academy and TEEI Mentorship run on Cloudflare Workers,
and those Workers hold no AWS keys. Every call they make to AWS is an HTTPS request to one Amazon
API Gateway HTTP API with 32 routes. Every request is authenticated before a product function runs.
The credential never carries a person&apos;s account id or email address. The authorizer ran 25,101 times in 30 days. It is kept warm with provisioned concurrency, and so is
the video-session function, so the first join of the morning does not wait for a cold start. A
separate health route lets every deploy prove that the Workers and AWS agree on the signing keys
before it goes live; it answered 17,474 times.

**A few narrow exceptions.** Some renderers (share cards and the company contribution reports) are
reached through Lambda function URLs with AWS IAM authentication. Each caller holds a credential
that can do one thing: invoke that one function. A small number of other narrowly scoped
credentials serve staff tools. Evenhall calls Amazon Bedrock with its own credential, through the
EU cross-Region inference profile.

**Most of the work is watching.** Of the 288,872 Lambda invocations in the 30 days to 14 October,
about half delivered findings to the team&apos;s board or watched the platforms from outside. The signed
door and its health route took about one in seven. The AI functions, the five checks described
below included, made 331 calls between them.

![Horizontal bar chart of 288,872 AWS Lambda invocations in the 30 days to 14 October 2026, by what the functions do. Findings to the team&apos;s board lead with 88,507, then monitoring from outside with 53,896, the signed door and health route with 42,575, and attendance and receipts with 40,471. The AI checks, practice and speech functions, marked in the accent colour, made 331 calls across 11 functions.](/images/blog/teei-aws/lambda-by-purpose.svg#phone,fit &quot;AWS Lambda invocations in 30 days, grouped by what the functions do. The AI functions are marked in the accent colour.&quot;)

Source: TEEI, from Amazon CloudWatch, the Invocations metric of all 82 functions from 14 September 14:00 to 14 October 14:00 UTC, read on 15 October 2026 at 06:09 UTC.

## Live video and captions

Every TEEI Language and TEEI Mentorship session, one-to-one or in a group, is an Amazon Chime SDK
meeting, and so are Skills Academy&apos;s live sessions.
When a person opens the session room, the Worker first checks that they belong in that
booking. Only then does it ask the video-session function for a meeting and an attendee token. The
function creates or reuses the meeting, returns the token and keeps no state of its own. From that
moment audio and video travel directly between the browser and the Chime media service in Ireland;
none of it passes through TEEI&apos;s servers.

In the 30 days to 13 October the video-session function ran 5,839 times, and Lambda recorded no
execution errors. Use grew through the summer, from 215 attendee-minutes in the first full week of
July to 17,036 in the week from 21 September; an attendee-minute is one person connected to a
meeting for one minute.

![Bar chart of Amazon Chime SDK attendee-minutes per week, Monday to Sunday, for the 14 full weeks from 6 July to 11 October 2026. Use rises almost every week, from 215 in the first week to a peak of 17,036 in the week of 21 September, and stands at 13,707 in the week of 5 October.](/images/blog/teei-amazon-chime-sdk/attendee-minutes-weekly.svg#wide,phone,fit &quot;Amazon Chime SDK attendee-minutes per full week, July to October 2026. With the days before the first full week and after the last, the total from 1 July to 13 October is 142,536.&quot;)

Source: TEEI, from AWS Cost Explorer, the daily usage of Amazon Chime SDK attendee-minutes across every meeting in our account, read on 15 October 2026 at 06:08 UTC.

Voice Focus echo reduction ran on practically all of those minutes from August onwards.

The room&apos;s small signals ride on Chime data messages: in-call chat, raised hands, reactions, and
mute and away state. Screen sharing uses Chime content share, and a host can remove a participant.
Live captions are Amazon Transcribe streaming, started through Chime. They are opt-in, and in a
one-to-one session both people have to agree before captions start. Transcribe identifies the
language being spoken, with English as the preferred language. Captions used 27,074 seconds of
streaming audio from July to 13 October.

Chime media pipelines are set up to record a short story that both people have agreed to. A
recording is capped at three minutes and stops the moment either person leaves. The feature is not
open to participants.

## Attendance: hours that start as meeting events

Chime publishes an event to Amazon EventBridge in Frankfurt whenever someone joins, leaves or drops
out, and when a meeting ends. A rule forwards those events to a small Lambda that hands each one to
the platform&apos;s ingest route; a second rule does the same for the Language staging environment. The
platform then settles attendance from the event history, after the session: the time both people
were connected, a five-minute minimum, settled from half an hour after the scheduled end once the
room is empty, waiting up to four hours past that end for a conversation that runs over. That
settlement is what verified volunteer hours are built from.

Those hours belong to volunteers like Heidi B., who comes to every session with a topic ready.

![Heidi B., a volunteer language partner in TEEI Language](https://r2.theeducationalequalityinstitute.org/videos/cockpit/square/heidi-b-1.mp4?v=2026-09-22b#square &quot;Heidi B., a volunteer language partner in TEEI Language, on preparing each session and meeting her learners regularly.&quot;)

Source: TEEI, a film recorded with Heidi B.&apos;s consent; this square cut rendered on 22 September 2026.

In the 30 days to 13 October the production forwarder ran 10,068 times. 33 attempts failed and were
retried. An event that still fails lands in a dead-letter queue, and a drain function replays that
queue every 15 minutes. On 14 October all 14 queues in the account, the 11 dead-letter queues
included, were empty.

A third rule writes an independent receipt of the main Chime meeting events, eight types in all,
into a versioned S3 bucket, with no names, email addresses or join tokens in it: 23,234 receipts in
those 30 days. An audit can compare that trail with what the platform recorded. Every hour a canary
checks that the routing rule still exists and is enabled, and sends a synthetic event all the way
through (722 runs).

## The test room: every room, opened in its roles

The London test environment also has a page of its own. It lists every room TEEI runs on Amazon
Chime SDK, from the booked one-to-one session and the Speaking Café to TEEI Meetings and Skills
Academy&apos;s webinars: 25 rows on 16 October 2026. Nothing on it is typed in by hand. The page is
computed when it loads, from the same code the rooms run: who may speak, who holds the host tools,
whether captions are offered, how many video tiles are shown, and where TEEI&apos;s settings differ from
the SDK&apos;s defaults, each with a link to the line of code that decides it.

Every room below opens with one press, signed in as one of the demo personas: the sample learners,
volunteers and hosts from TEEI&apos;s public tours. The environment has its own database, so no
participant&apos;s data is in it. The rooms are open to anyone reading this.


&lt;figure&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;&lt;/span&gt;Test room · London · demo personas only&lt;/span&gt;&lt;span&gt;Skills Academy webinar, as a listener&lt;/span&gt;&lt;/div&gt;&lt;div&gt;&lt;img src=&quot;/images/blog/teei-aws/test-room-stage.webp&quot; width=&quot;1440&quot; height=&quot;1180&quot; alt=&quot;A Skills Academy webinar in the TEEI test room, seen by a listener. On the left the room, hosted by Fatima Al-Sayed, with a Join the session button. On the right the AWS trace panel with its tabs Join, Media, Capabilities, Captions, Events, Settle and Fault path, open at Join, which lists each step of the join with its time.&quot; /&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;3&lt;/span&gt;&lt;span&gt;4&lt;/span&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;p&gt;One press&lt;/p&gt;&lt;p&gt;You are in the room as a listener, with the AWS trace open beside it.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=skills-webinar&amp;amp;as=participant-1&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Enter the webinar as a listener &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=skills-webinar&amp;amp;as=host&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;as the host&lt;/a&gt;&lt;/p&gt;&lt;p&gt;Open the host in one tab and a listener in another to see both sides.&lt;/p&gt;&lt;/div&gt;&lt;div&gt;&lt;p&gt;Join, step by step &lt;span&gt;measured 15 October 2026&lt;/span&gt;&lt;/p&gt;&lt;ol&gt;&lt;li&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;Who may enter&lt;/span&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;&lt;span&gt;0 ms&lt;/span&gt;&lt;/li&gt;&lt;li&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;Meeting lease&lt;/span&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;&lt;span&gt;95 ms&lt;/span&gt;&lt;/li&gt;&lt;li&gt;&lt;span&gt;3&lt;/span&gt;&lt;span&gt;Signed token&lt;/span&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;&lt;span&gt;0 ms&lt;/span&gt;&lt;/li&gt;&lt;li&gt;&lt;span&gt;4&lt;/span&gt;&lt;span&gt;API Gateway&lt;/span&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;&lt;span&gt;125 ms&lt;/span&gt;&lt;/li&gt;&lt;li&gt;&lt;span&gt;5&lt;/span&gt;&lt;span&gt;Meeting Lambda&lt;/span&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;&lt;span&gt;52 ms&lt;/span&gt;&lt;/li&gt;&lt;li&gt;&lt;span&gt;6&lt;/span&gt;&lt;span&gt;Media&lt;/span&gt;&lt;span&gt;your browser, straight to Amazon Chime SDK&lt;/span&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;/div&gt;&lt;ol&gt;&lt;li&gt;&lt;b&gt;1&lt;/b&gt;&lt;span&gt;The room. The host is Fatima Al-Sayed, a demo persona; you join muted, as a listener.&lt;/span&gt;&lt;/li&gt;&lt;li&gt;&lt;b&gt;2&lt;/b&gt;&lt;span&gt;One press joins. Step one, who may enter, is decided before anything reaches AWS.&lt;/span&gt;&lt;/li&gt;&lt;li&gt;&lt;b&gt;3&lt;/b&gt;&lt;span&gt;The AWS trace: Join, Media, Capabilities, Captions, Events, Settle and Fault path.&lt;/span&gt;&lt;/li&gt;&lt;li&gt;&lt;b&gt;4&lt;/b&gt;&lt;span&gt;The join token, decoded. The Worker removes the account id before it writes the trace.&lt;/span&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/figure&gt;
&lt;section&gt;&lt;div&gt;&lt;p&gt;Twelve rooms, one press each&lt;/p&gt;&lt;a href=&quot;/partners/aws/chime/&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;All 25 rows, with the code behind each &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;&lt;p&gt;&lt;span&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;everyone speaks&lt;/span&gt;&lt;span&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;the host presents&lt;/span&gt;&lt;span&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;listeners are invited up&lt;/span&gt;&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;p&gt;TEEI Language&lt;/p&gt;&lt;p&gt;Booked one-to-one&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;Everyone speaks&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=booked-1to1&amp;amp;as=learner&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Learner &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=booked-1to1&amp;amp;as=volunteer&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Volunteer&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;TEEI Language&lt;/p&gt;&lt;p&gt;Speaking Café&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;Everyone speaks&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=speaking-cafe&amp;amp;as=host&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Host &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=speaking-cafe&amp;amp;as=participant-1&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Learner&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;TEEI Language&lt;/p&gt;&lt;p&gt;Workshop&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;The host presents &lt;span&gt;held by the room&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=workshop&amp;amp;as=host&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Host &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=workshop&amp;amp;as=participant-1&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Learner&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;TEEI Language&lt;/p&gt;&lt;p&gt;Peer practice&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;Everyone speaks&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=peer-practice&amp;amp;as=participant-1&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Learner 1 &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=peer-practice&amp;amp;as=participant-2&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Learner 2&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;TEEI Language&lt;/p&gt;&lt;p&gt;Guest in a call&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;Everyone speaks&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=guest-call&amp;amp;as=volunteer&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Volunteer &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=guest-call&amp;amp;as=guest&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Guest&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;TEEI Mentorship&lt;/p&gt;&lt;p&gt;Booked mentoring&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;Everyone speaks&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=booked-mentoring&amp;amp;as=mentor&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Mentor &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=booked-mentoring&amp;amp;as=mentee&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Mentee&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;TEEI Mentorship&lt;/p&gt;&lt;p&gt;Group mentoring&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;Everyone speaks&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=group-mentoring&amp;amp;as=host&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Host &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=group-mentoring&amp;amp;as=participant-1&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Participant&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;Skills Academy&lt;/p&gt;&lt;p&gt;Webinar&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;Invited up to speak &lt;span&gt;Chime enforces&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=skills-webinar&amp;amp;as=host&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Host &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=skills-webinar&amp;amp;as=participant-1&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Participant&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;Skills Academy&lt;/p&gt;&lt;p&gt;Class&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;Everyone speaks&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=skills-class&amp;amp;as=host&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Host &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=skills-class&amp;amp;as=participant-1&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Participant&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;Skills Academy&lt;/p&gt;&lt;p&gt;Expert Q&amp;amp;A&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;Invited up to speak &lt;span&gt;Chime enforces&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=expert-qa&amp;amp;as=host&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Host &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=expert-qa&amp;amp;as=participant-1&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Participant&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;TEEI Meetings&lt;/p&gt;&lt;p&gt;Meeting&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;Everyone speaks&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=meeting&amp;amp;as=host&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Host &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=meeting&amp;amp;as=guest&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Guest&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;TEEI Meetings&lt;/p&gt;&lt;p&gt;Webinar&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;Invited up to speak &lt;span&gt;Chime enforces&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=meeting-webinar&amp;amp;as=host&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Host &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=meeting-webinar&amp;amp;as=listener&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Listener&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;&lt;span&gt;Swipe for all twelve rooms&lt;/span&gt;&lt;span&gt;→&lt;/span&gt;&lt;/p&gt;&lt;p&gt;Each button opens the room in a new tab, in that role. A running room is reused; if its demo personas are all in other rooms, you land on the overview and press again a few minutes later.&lt;/p&gt;&lt;/section&gt;

Once you are in, a panel beside the room shows what happened on AWS. A group room or a meeting
lets you in as it opens; a one-to-one call waits for you to press Join:

- **Join**: each step of the join with its timing, the AWS X-Ray trace id, and which function
  answered.
- **Media**: packet loss, round-trip time, the video layer being received, the audio profile, and
  whether Voice Focus is on.
- **Capabilities**: in a Skills Academy webinar, the `UpdateAttendeeCapabilities` call made when
  the host invites a listener to speak, as sent and as answered.
- **Captions**: Amazon Transcribe captions in a one-to-one session, with a count of the caption
  bytes that passed through TEEI&apos;s servers. The count stays at zero, because the captions reach the
  browser from Chime.
- **Events**: every EventBridge event for the meeting, the fields the forwarder sends, what the
  ingest did with it, and its receipt in S3 once the independent rule has written it.
- **Settle**: the attendance settlement, run for this one room at once instead of after the usual
  wait, with its outcome and the time both people were connected.
- **Fault path**: in the test environment only, the ingest can be set to refuse the next event. A
  reader then watches that event refused three times, land in the dead-letter queue, and come
  back stored when the queue is drained.

&lt;div&gt;&lt;p&gt;Inside the trace&lt;/p&gt;&lt;div&gt;&lt;figure&gt;&lt;div&gt;&lt;img src=&quot;/images/blog/teei-aws/test-room-trace-join.webp&quot; width=&quot;432&quot; height=&quot;1180&quot; alt=&quot;The Join tab of the trace: six steps from the click to media, each with its time, from who may enter to the meeting Lambda.&quot; /&gt;&lt;/div&gt;&lt;figcaption&gt;&lt;b&gt;Join.&lt;/b&gt; Six steps from the click to media, each with its time and its answer.&lt;/figcaption&gt;&lt;/figure&gt;&lt;figure&gt;&lt;div&gt;&lt;img src=&quot;/images/blog/teei-aws/test-room-trace-capabilities.webp&quot; width=&quot;432&quot; height=&quot;900&quot; alt=&quot;The Capabilities tab: the UpdateAttendeeCapabilities call as sent and as answered, HTTP 200 in 133 ms.&quot; /&gt;&lt;/div&gt;&lt;figcaption&gt;&lt;b&gt;Capabilities.&lt;/b&gt; The UpdateAttendeeCapabilities call when a host invites a listener up, as sent and as answered: HTTP 200 in 133 ms.&lt;/figcaption&gt;&lt;/figure&gt;&lt;figure&gt;&lt;div&gt;&lt;img src=&quot;/images/blog/teei-aws/test-room-trace-fault.webp&quot; width=&quot;432&quot; height=&quot;470&quot; alt=&quot;The Fault path tab: an Arm switch, the dead-letter queue count and a Drain now button.&quot; /&gt;&lt;/div&gt;&lt;figcaption&gt;&lt;b&gt;Fault path.&lt;/b&gt; Armed, the ingest refuses the next event three times; it lands in the dead-letter queue, and Drain replays it.&lt;/figcaption&gt;&lt;/figure&gt;&lt;/div&gt;&lt;p&gt;Source: TEEI, captured in the London test environment on 15 October 2026, with demo personas only. Identifiers are removed from the images.&lt;/p&gt;&lt;/div&gt;

On 15 October 2026 a scripted walk with two browsers joined a Skills Academy webinar in the test
room as host and listener, and passed all 23 of its steps. The listener raised a hand, the host
invited them to speak, and the capability call answered with HTTP 200 in 115 ms. The meeting&apos;s
events arrived with their S3 receipts. An earlier walk the same day ran the fault path end to end:
three refusals, the event in the dead-letter queue, and the event stored after the drain.

## AI on Amazon Bedrock, and the checks around it

The AI checks at TEEI run on Claude Sonnet 4.6 in Amazon Bedrock, called through the EU cross-Region
inference profile, so requests are processed inside EU Regions. The product functions share one
configuration: that model, and a shared Bedrock guardrail at a pinned, numbered version. The
infrastructure refuses to deploy a draft guardrail version.

Five AI checks run for participants today.

**Volunteer screening.** When someone applies to volunteer, the model reads the application at
temperature zero through a forced tool and returns a structured verdict: a decision, red flags,
ambiguities, positive signals, a confidence score and a seriousness rating. The application is
passed to the model as untrusted data. Only the decision, the counts and the token numbers are
recorded; the application text is not. Automatic approval is restricted to TEEI Language
conversation partners who pass screening; mentors and course teachers always go to a person,
whatever the verdict. 99 screenings ran in 30 days.

**Chat safeguarding.** The model flags chat concerns for staff, who decide how to respond. It never
acts by itself. The message text is not recorded. 81 checks ran in 30 days.

**The help assistant.** It answers only from TEEI&apos;s help articles and checks its citations against
them before an answer is shown; when it cannot answer, the person is told so and can reach a
member of staff. A question that touches on safety
gets emergency guidance and no model text at all. On the public website the assistant answers only
from four approved public FAQ lists. It sits behind the shared guardrail. 98 questions in 30 days.

**Return plans.** When a volunteer has been taken off the listings and submits a plan to return, the
model reports what the plan contains: eight yes or no items, anything missing, and any red flags. It
does not decide. Programme rules decide, and in the most serious cases a member of staff does. The
model never relists anyone. There is a matching review for learners. Three reviews ran over the same
30 days.

**The group practice guide.** In group practice, the volunteer leading the session can ask for a
guide pitched across the learners&apos; levels. The volunteer decides whether to use it.

Table: The five AI checks and who makes the final decision.

| Check | Who makes the final decision |
|---|---|
| Volunteer screening | the platform, automatically, only for Language conversation partners who pass screening; staff for everyone else |
| Chat safeguarding | staff |
| Help assistant | the assistant answers, and only with a surviving citation |
| Return plans | the platform&apos;s policy, and staff at the strictest level |
| Group practice guide | the volunteer |

**A prompt cannot ship without a passing evaluation.** Every check whose prompt decides something
has an evaluation guard that runs when the infrastructure is synthesised. It fingerprints the
prompt, the decision rule and the output contract. If any of them changes, the fingerprint no
longer matches the committed evaluation receipt, and the deploy stops until the evaluation has
been run again against Bedrock and has passed.

Table: The evaluation each check must pass before its prompt can ship.

| Check | Evaluation on record | What it must achieve |
|---|---|---|
| Volunteer screening | 75 cases | zero false automatic approvals, zero errors |
| Mentor screening | 94 cases, 360 calls | safety cases refused in at least 80 % of runs, ordinary applications passed in at least 90 % |
| Chat safeguarding | 18 cases | zero escapes, zero errors |
| Return plans (volunteers, learners) | 14 cases each | zero unsafe, zero harmful, zero failed |
| Level reading | 60 cases, five repeats, three separate sets | exact level agreement of at least 0.75, never more than one band off, zero inflated levels under adversarial input |
| Practice reply | 17 cases, 34 samples | zero on every listed fault, from errors to leaked instructions |

The last two guard features that are built but not open to participants. Our
[note on Practice Companion&apos;s rooms](/blog/inside-practice-companion-rooms-evidence-and-level-en/)
describes all four rooms, how they are built on AWS and how the companion reads a learner&apos;s level.

![Three phone screens from Practice Companion. On the left, the Write room: the learner has written No, I working morning. Can after three?, and one correction appears under it, working to work, with the reason in a sentence. In the middle, the Say room: the receptionist&apos;s line, played by AI, then Say it in your own words and a line the learner could say. On the right, the level conversation at question three of six, with the learner&apos;s earlier answers and the companion&apos;s next question.](/images/blog/practice-companion/room-phones-1.webp#phones,390x844 &quot;Practice Companion, the feature those last two evaluations guard. It is built and not open to participants.&quot;)
![](/images/blog/practice-companion/room-phones-2.webp#phones,390x844)
![](/images/blog/practice-companion/room-phones-3.webp#phones,390x844)

Source: TEEI, captured from Practice Companion&apos;s rooms on our development server, with fixture data in place of a real learner, 18 October 2026.

**The guardrails.** The shared guardrail filters harmful content and screens prompts for attacks. It
refuses medical and legal advice, blocks card numbers and passwords, and masks email addresses,
phone numbers, bank account numbers and similar identifiers. Practice conversations have their own
role-play guardrail with a lower violence threshold, because in testing the shared one blocked 4 of
35 harmless learner lines. Practice replies receive an additional safety check on their structured
output.

In 30 days the guardrails ran 72,427 checks, 40,570 on input and 31,857 on output. The role-play
guardrail ran about 48,700 of them and stepped in 37 times; the shared guardrail ran about 8,600 on
its pinned version and stepped in 269 times. These totals cover every guardrail in the account,
evaluation runs included, which CloudWatch counts alongside live traffic.

**What is recorded.** TEEI leaves Bedrock model invocation logging off, so its own account keeps no
copy of prompts or replies. The product functions are built to record outcomes, counts and token
numbers rather than text, and each one has a per-person hourly limit and a reserved share of
concurrency.

## Speech and media

- **Uploaded video.** Participants and applicants upload testimonial, profile and work videos
  straight from the browser to Amazon S3 with presigned URLs (2,770 requests in 30 days). Each
  upload starts one AWS Elemental MediaConvert job that produces HTTP Live Streaming (HLS), an MP4
  fallback and a poster frame, and a second function reports the result back to the platform: 867
  jobs and 867 reports in 30 days. Deleting a video also cancels any job still running on it.
- **Photographed homework.** A learner can photograph handwritten work and send it to the volunteer
  who asked for it, through its own bucket (389 requests).
- **Speech.** Amazon Polly neural voices synthesised about 1,500 characters a month.
- **Voice practice.** A WebSocket server for spoken practice runs on Amazon ECS on AWS Fargate
  behind an Application Load Balancer, one task for staging and one for production. The ElevenLabs
  Speech Engine, which is not an AWS service, dials it, and it answers with Claude Haiku 4.5 through
  the EU profile behind the role-play guardrail. It is open to staff only.

## Events and analytics

The platform sends product events to AWS through an events proxy (739 calls in 30 days). An event
logger writes each one to DynamoDB and to an S3 analytics store in date partitions (1,063 runs),
which AWS Glue describes and Amazon Athena queries. An event never carries an account id. DynamoDB
holds 14 tables in all, among them the
consent log, pathway progress, rate limits and email suppression, with point-in-time recovery.

## Data safety and the database move

The platform&apos;s main database is Turso (libSQL), not AWS. AWS holds the safety copies: a nightly
export to S3 kept for 180 days.

The database is moving to AWS. The new home is the libSQL server on an AWS Graviton instance in its
own VPC with no inbound port at all; the only way in is through Cloudflare Tunnel and Cloudflare
Access. Backups stream continuously to S3, and AWS Backup takes hourly, daily and monthly recovery
points into locked vaults, with a copy in Frankfurt. Every new backup object gets an S3 Object Lock
retention within seconds of upload. Every minute a canary proves from outside that a request
without a token is refused and that Cloudflare Access stops a request with no credentials at all.
There are two hosts: one has served a demo since 5 October, and one for production was created on
11 October. Production still runs on Turso. The three locked vaults held 53, 47 and 12 recovery
points on 14 October.

## Monitoring from outside

The platform runs on Cloudflare, so much of its monitoring deliberately runs on AWS, where a
Cloudflare outage cannot silence it.

Table: The watches that run on AWS and their runs in 30 days. Grouped by how often each one runs.

| Watch | Runs in 30 days {bar} | Note |
|---|---|---|
| Every minute | | |
| Findings delivered as tickets | 61,535 | to the team&apos;s board, and on events as well |
| Every five minutes | | |
| Video client-log collector | 17,282 | one run per programme |
| Portal watch from Frankfurt | 8,653 | public health, the internal scheduler heartbeat, and that each Worker can still sign in to AWS |
| Reliability monitor | 8,641 | external liveness, that the required alert rules exist, and that alerts are delivered |
| Independent snapshot collector | 8,640 | so the monitor never vouches for itself |
| CloudWatch Synthetics canary | 8,640 | through Evenhall&apos;s public journey |
| Every hour | | |
| Observer | 720 | over Worker logs, error reports, product analytics and the support queue, with no model involved |
| Total | 114,111 | |

Behind those sit 176 CloudWatch alarms, a bridge that turns AWS alarms into Sentry events, an
inbound route for Sentry&apos;s own alerts, and AWS X-Ray tracing on the product functions. The 21
most serious alarms, among them the API&apos;s error rate, sign-in and video-session failures and the
database guards, page the team by SMS through Amazon SNS.

## Security and the account guard

- **AWS CloudTrail** records every Region into a bucket under S3 Object Lock for a year, with write
  events on the database buckets.
- **Amazon GuardDuty** is on; a high-severity finding pages the team.
- **Amazon Inspector** scans the container images in Amazon ECR (13 repositories).
- **AWS KMS** holds seven customer-managed keys: for data, for the two database hosts, for the
  Frankfurt backup copy, for signing build manifests, for signing release artefacts, and for signing
  published impact figures.
- **AWS Secrets Manager** holds 63 secrets.
- **GitLab CI** reaches AWS through OpenID Connect, with a narrowly scoped role and no stored key.
- Public sharing of EBS snapshots is blocked for the account.
- **AWS Budgets** watches the account with eight budgets.

## Proofs, PDFs and images

- **Signed impact figures.** Each published snapshot of TEEI&apos;s impact figures is signed with an
  asymmetric AWS KMS key whose private half never leaves AWS, and a copy is kept in a bucket under
  S3 Object Lock in compliance mode for ten years. Nobody, TEEI included, can change or delete it
  in that time. A headless Chromium function prints the snapshot as a PDF with its digest and
  signature fingerprint in the footer (9 runs in 30 days).
- **Company contribution reports.** Evenhall&apos;s contribution report becomes a PDF in one of two
  renderers: WeasyPrint (11 production runs in 30 days), or Chromium printing the same component the
  company sees on screen, so the PDF follows the screen (36 production runs and 107 in staging).

![Four pages of a sample company contribution report side by side. The cover reads Learning support, measured, for the sample company Horizon Technologies, with 36.90 measured volunteer hours. Page two, Contribution at a glance: 36.90 hours, 44 completed sessions, 20 learners reached, 5 countries and 4 languages, and how the count is reached from 52 bookings on file. Page three, Who contributed: six sample volunteers with their programme, sessions, measured hours and share of the hours. Page four, Monthly activity: completed sessions and measured hours for each month from January to September 2026.](/images/blog/teei-aws/contribution-page-1.webp#document,794x1123 &quot;The first four of nine pages of a contribution report, printed by the Chromium renderer from the component the company sees on screen.&quot;)
![](/images/blog/teei-aws/contribution-page-2.webp#document,794x1123)
![](/images/blog/teei-aws/contribution-page-3.webp#document,794x1123)
![](/images/blog/teei-aws/contribution-page-4.webp#document,794x1123)

Source: TEEI, rendered on 16 October 2026 by Evenhall&apos;s Chromium PDF renderer, run locally from its source, for an invented company with sample data only. The company and the volunteer names are the ones in Evenhall&apos;s public demo. Record incomplete, on every page, is the report saying correctly that no filed record stands behind a sample.

- **Credential artwork.** Every five minutes a function draws the artwork of each new credential
  and the look its holder chose, into Cloudflare R2: 4,364 production runs in 30 days.

![A sample credential in the three forms the artwork function draws. On the left, an A4 certificate of volunteer service awarded to Moira Sinclair, Senior Language Volunteer, for 10 verified sessions with 4 learners in Ukraine, issued 14 October 2026, with the verification code at its foot. On the right, the square badge with the same facts over an evening photograph, and below it the wide card shown when the credential is shared on LinkedIn.](/images/blog/teei-aws/credential-certificate.webp#document,1123x794 &quot;One credential, drawn three ways by the same function: the certificate, the badge and the share card.&quot;)
![](/images/blog/teei-aws/credential-badge.webp#document,800x800)
![](/images/blog/teei-aws/credential-share.webp#document,1200x630)

Source: TEEI, drawn on 16 October 2026 with the template the production function draws from, for a sample record. Moira Sinclair is a demo persona from the product tour, and the code is an example that verifies nothing.

- **Share cards.** A volunteer can share a session they teach as a card in link, feed or story
  format. The production renderer was created on 13 October.

## Build and release: the CodeBuild lane, and why it is off

From August to October 2026, TEEI verified release candidates of TEEI Language and TEEI Mentorship
on AWS CodeBuild. An AWS Step Functions state machine per product fanned the work out in one
parallel step: 15 jobs for Language and 12 for Mentorship. They covered quick preflight checks;
API, route and server contracts; every database migration applied to a clean database; four shards
of logic tests and two of database-heavy replays; four browser shards running the full Playwright
release sweep; and two advisory lanes, a secret scan with a software bill of materials and a set of
journeys that prove a save survives a reload. Each job ran in a container image pinned by digest
against one exact S3 object version of the candidate. The commands were embedded in the state
machine, so a run could not supply its own. The result was a manifest written exactly once and
signed with an AWS KMS key. A separate release executor prepared and promoted releases. 9,751 builds
are on record: 5,829 logic and contract jobs, 2,749 browser jobs, 766 security scans, 267 review
publications, 99 release preparations and 20 promotions, and a few dozen more for the main site,
Evenhall and the video test plane.

**The off switch.** We could not get the parallel lane to run reliably end to end. On 11 October
2026 TEEI switched it off. An explicit IAM deny forbids starting any lane state machine or
CodeBuild project, and the release scripts in the product repository refuse before they make any
AWS call. CodeBuild has recorded zero build minutes from 12 October.

The lane worked hardest just before it stopped. The week from 28 September ran 3,151 builds, about
a third of everything on record, and the week of the switch-off still ran 1,850. No build has started
since.

![Bar chart of AWS CodeBuild builds per week, Monday to Sunday, from the week of 3 August to the week of 12 October 2026. Builds rise unevenly to a peak of 3,151 in the week of 28 September and 1,850 in the week of 5 October. A numbered marker on 11 October shows the lane being switched off, and the week of 12 October has no builds.](/images/blog/teei-aws/codebuild-weekly.svg#wide,phone,fit &quot;AWS CodeBuild builds per week, from the first build on 7 August until the lane was switched off on 11 October 2026: 9,751 in all.&quot;)

Source: TEEI, from AWS CodeBuild, the start time of every build on record (its projects are in Ireland and London), read on 15 October 2026 at 06:16 UTC.

**What replaced it.** The same job list, from the same lane definitions, now runs on one build
machine: an Amazon EC2 m7i.8xlarge (32 vCPUs, with a 500 GiB gp3 volume at 16,000 IOPS) in
Ireland, in the same account. Each job runs at the exact commit, results are reused only when the
source tree, lockfile, job script and lane definitions all match, and the deploy accepts the
manifest it writes. The machine runs from 06:00 to midnight Oslo time on an Amazon EventBridge
Scheduler schedule.

The CodeBuild projects, images, roles and state machines are still deployed, switched off rather
than deleted.

## Built, not yet open

Table: Product features built on AWS and not yet open to participants.

| Part | State on 14 October 2026 |
|---|---|
| Practice conversation, level conversation, Skills Academy feedback, recorded stories | built, behind flags, not open to participants |
| Mentor profile, career advice | deployed, no calls in 30 days |
| Voice practice | open to staff only |

Practice conversation and the level conversation open only after they pass L2SAFE, the benchmark
TEEI Research built to test whether a model understands and answers a question as safely in learner
language as in fluent language.

## The rest of the plumbing

Table: The other AWS services and what each one does at TEEI.

| Service | What it does at TEEI |
|---|---|
| Amazon EventBridge | 72 rules in Ireland, 7 in Frankfurt and 7 in London: alarm routing, findings, database guards, the Chime event rules, and the schedules that drive the watches above |
| EventBridge Scheduler | five schedules: credential artwork (twice), the observer, and the build machine&apos;s morning start and night stop |
| AWS Step Functions | seven state machines, kept from the CodeBuild lane |
| Amazon SQS and Amazon SNS | 14 queues, all empty on 14 October; eight topics for alerts |
| Amazon ECS, Elastic Load Balancing, Amazon VPC | the voice practice service; three load balancers; five VPCs, no NAT gateways |
| Amazon EC2 | three instances: the build machine and the two database hosts |
| Amazon S3 | 43 buckets, seven of them under Object Lock |
| AWS Certificate Manager | four certificates, all in use |</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>AWS</category><category>Amazon Bedrock</category><category>Amazon Chime SDK</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>How TEEI Turns Amazon Chime SDK Events into Verified Hours</title><link>https://theeducationalequalityinstitute.org/blog/from-practice-room-to-company-report-teei-amazon-chime-sdk-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/from-practice-room-to-company-report-teei-amazon-chime-sdk-en/</guid><description>TEEI&apos;s free programmes run on Amazon Chime SDK, and every verified volunteer hour, up to the company report in Evenhall, starts as a meeting event.</description><pubDate>Wed, 14 Oct 2026 00:00:00 GMT</pubDate><content:encoded>TEEI&apos;s free programmes connect people from Ukraine, and women from Latin America and the Caribbean, with volunteers who help them practise a language and build a career. In [TEEI Language](/programmes/education/teei-language/), [Skills Academy](/programmes/education/skills-academy/) and [TEEI Mentorship](/programmes/education/teei-mentorship/), every live conversation runs on [Amazon Chime SDK](https://aws.amazon.com/chime/chime-sdk/features/), and every verified volunteer hour starts as a meeting event, not a calendar entry.

A calendar booking says that two people planned to meet. It does not say that they did. For TEEI, a nonprofit, that gap matters: volunteers want their hours to count, programmes report what happened, and companies whose employees volunteer with us need figures they can stand behind.

So we built our record of participation on the meeting itself. The three programmes share our own platform, and when someone joins or leaves one of its rooms, the SDK emits an event. Events become connected time, connected time becomes a settled session, and settled sessions become the verified hours that learners, volunteers and companies see. Each number can be traced back to what happened in a meeting.

This article follows that path, from the practice room to the company report, and then opens up the AWS architecture underneath: how Amazon Chime SDK connects to the other AWS services we run, and the settings we chose. It ends in a [test room](#the-test-room-every-room-opened-in-its-roles) where you can open every room in its roles.

**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.

## At a glance

Table: The parts of TEEI and what Amazon Chime SDK provides in each.

| Part of TEEI | What happens there | What Amazon Chime SDK provides |
|---|---|---|
| TEEI Language | One-to-one practice, Speaking Clubs and other group practice | Meetings, audio and video, Voice Focus, data messages, live transcription |
| TEEI Language Courses | Teacher-led online group classes, two to three sessions a week, with materials and assignments in itslearning | Nothing yet: the platform has no Amazon Chime SDK room for these classes |
| Skills Academy | Live workshops, clinics, expert Q&amp;amp;A, webinars and classes | Group meetings, and per-attendee capabilities that decide who may speak |
| TEEI Mentorship | One-to-one and group mentoring | The same rooms, under Mentorship&apos;s own words and rules |
| TEEI Meetings | Private meetings with invited guests | Attendee capabilities for hosts, speakers and listeners |
| Evenhall by TEEI | Companies organise employee volunteering and reporting | Meeting events, from which TEEI builds the participation record |

## One room layer, several programmes

TEEI&apos;s programmes form one path, from language to skills to mentorship, and a person can start at any step. Each programme looks different to the people in it. TEEI Language matches learners with volunteers. Skills Academy publishes classes, enrols participants and decides who teaches. TEEI Mentorship matches mentees with mentors and has its own journey and vocabulary. Matching, booking, enrolment, class administration and certificate rules are TEEI&apos;s own software.

The conversation is shared. Every programme uses the [Amazon Chime SDK client library for JavaScript](https://docs.aws.amazon.com/chime-sdk/latest/dg/meetings-sdk.html) and the meeting APIs directly, so the room is a page in our product rather than a link to another app. People open it from their own TEEI page, check camera and microphone in a lobby, and talk. If the other person does not arrive, the room says so and shows the way back.

![The lobby before a one-to-one session in TEEI Language. On the left, the start time in Oslo and in Kyiv, and the learner&apos;s words from booking: I would like to practise talking about my work. On the right, the volunteer&apos;s camera preview, a button to join, and the camera, microphone and background blur settings.](/images/blog/teei-amazon-chime-sdk/room-lobby.webp#full,phone,1280x800 &quot;The lobby: the time in both cities, what the learner asked for when booking, and a camera and microphone check before joining.&quot;)

Source: TEEI, captured in a live Amazon Chime SDK meeting, with demo personas in place of real participants, 18 October 2026.

The one-to-one room is the same in TEEI Language and TEEI Mentorship; only the words, labels and programme rules change.

![A one-to-one session in progress on a laptop. The learner fills the screen, the volunteer&apos;s own picture sits in the corner, the time left is shown at the top, and the controls for microphone, camera, screen sharing, raised hand, reactions, captions, chat and notes run along the bottom.](/images/blog/teei-amazon-chime-sdk/room-one-to-one.webp#full,phone,1280x800 &quot;A one-to-one session: the other person fills the screen, and everything else stays at the edges.&quot;)

Source: TEEI, captured in a live Amazon Chime SDK meeting, with demo personas in place of real participants, 18 October 2026.

The room is the same; what happens in it belongs to the two people. In TEEI Language, a volunteer often opens with something easy to talk about.

![Charmaine D., a volunteer language partner in TEEI Language](/r2/videos/teei-language/charmaine-d-clip-29s.mp4 &quot;Charmaine D., a volunteer language partner in TEEI Language, on how she opens a first conversation: with travel, so the learner feels comfortable.&quot;)

Source: TEEI, a film recorded with Charmaine D.&apos;s consent; this 29-second cut made on 15 October 2026.

In TEEI Mentorship, the same room starts from the mentee&apos;s question.

![Tatyana M., a TEEI Mentorship mentor](https://r2.theeducationalequalityinstitute.org/videos/cockpit/square/tatyana-m-1.mp4?v=2026-09-22b#square &quot;Tatyana M., a TEEI Mentorship mentor, on asking each mentee what the session should be: career advice, a conversation or support.&quot;)

Source: TEEI, a film recorded with Tatyana M.&apos;s consent; this square cut rendered on 22 September 2026.

Volunteers also share their own careers in short films for TEEI Mentorship. Dana A., who works at AWS, taught herself to code.

![Dana A., a TEEI volunteer who works at AWS](https://r2.theeducationalequalityinstitute.org/videos/cockpit/square/dana-a-1.mp4?v=2026-09-22b#square &quot;Dana A., a TEEI volunteer who works at AWS, on six months from her first coding lesson to an interview request from Amazon, and why to start real projects early.&quot;)

Source: TEEI, a film recorded with Dana A.&apos;s consent; this square cut rendered on 22 September 2026.

The group room carries Speaking Clubs, group mentoring and Skills Academy&apos;s live sessions. It uses the SDK&apos;s attendee presence, volume indicator and active speaker detection to show who is there and who is talking, with up to 25 videos at once, the SDK&apos;s default for a meeting. Our application adds the host&apos;s tools: conversation cards, raised hands in order, a spotlight and an invitation to speak.

![A Speaking Club with five people. The host is speaking in the large tile, a conversation card asks: What is one small thing that made you smile this week? Two participants show their video, and two have their cameras off and appear as initials.](/images/blog/teei-amazon-chime-sdk/room-speaking-club.webp#full,phone,1280x800 &quot;A Speaking Club: the host is speaking, a conversation card gives the group something to talk about, and two people keep their cameras off.&quot;)

Source: TEEI, captured in a live Amazon Chime SDK meeting, with demo personas in place of real participants, 18 October 2026.

In Skills Academy, volunteers with professional skills teach live: one-off workshops, clinics, expert Q&amp;amp;A sessions and webinars, and classes that meet weekly for two to six sessions. Its self-paced study on DataCamp and Coursera takes place on those platforms, not in our rooms. One sign-in carries a person into Skills Academy from TEEI Language or TEEI Mentorship. Instructors who apply on their own are approved before they teach, while a colleague a company sends creates and teaches on topics they choose. Participants enrol, and a class certificate follows when the teaching team confirms attendance and reviewed work.

In these sessions, the SDK does more than carry media. Each session records who is host, co-host, moderator or speaker, and everyone else listens. In presentation and panel formats such as a webinar, our backend uses [attendee capabilities](https://docs.aws.amazon.com/chime-sdk/latest/APIReference/API_meeting-chime_AttendeeCapabilities.html) so that a listener receives audio without sending it, and [UpdateAttendeeCapabilities](https://docs.aws.amazon.com/chime-sdk/latest/APIReference/API_meeting-chime_UpdateAttendeeCapabilities.html) lets a host invite someone to speak and return them to listening. A host can also remove a participant or end the meeting. Workshops, clinics and classes are built for conversation, so everyone in them can speak. Presenting and screen sharing follow the same roles in the room. TEEI Meetings, where staff meet invited guests who join from a personal link without an account, uses the same mechanism: a webinar listener receives audio, video and shared content and sends none of them.

![The host&apos;s view of the same Speaking Club with Host tools open. One raised hand waits beside a button to invite that person to speak, and below are conversation cards, a spotlight, mute everyone, settle the room and end the club for everyone.](/images/blog/teei-amazon-chime-sdk/room-host-tools.webp#full,phone,1280x800 &quot;Host tools: raised hands in order, an invitation to speak, conversation cards and the controls for the whole room. Only the host sees them.&quot;)

Source: TEEI, captured in a live Amazon Chime SDK meeting, with demo personas in place of real participants, 18 October 2026.

## Built for everyday life in Ukraine

For many of our participants in Ukraine, a power cut or a phone switching networks mid-sentence is part of an ordinary day. That is why our configuration starts from speech and from coming back.

When we measured our own production calls, the main problem was packet loss, not latency. So we use the SDK&apos;s speech audio profile with redundancy, which sends extra copies of the audio so the listener can recover lost packets: a lesson can survive losing the picture, but not losing the voice. We allow five minutes to reconnect instead of the default two, so a conversation interrupted by a power cut or a network handover continues when the person returns, rather than starting over. Each device receives the video its connection can carry, and TEEI never turns anyone&apos;s camera off automatically.

![Two phone screens side by side. On the left, a one-to-one session with the volunteer&apos;s video between the time left and the controls. On the right, a Speaking Club with a conversation card at the top, the host speaking, and four participants in a grid below.](/images/blog/teei-amazon-chime-sdk/room-phones-1.webp#phones,390x844 &quot;The same rooms on a phone: a one-to-one session and a Speaking Club.&quot;)
![](/images/blog/teei-amazon-chime-sdk/room-phones-2.webp#phones,390x844)

Source: TEEI, captured in a live Amazon Chime SDK meeting, with demo personas in place of real participants, 18 October 2026.

Noise matters as much as loss. [Amazon Voice Focus](https://aws.github.io/amazon-chime-sdk-js/modules/amazonvoice_focus.html) reduces background noise in the browser, and every meeting requests [echo reduction](https://docs.aws.amazon.com/chime-sdk/latest/dg/js-meeting-features.html), which helps when someone listens through laptop speakers in a shared room. A control in the room switches Voice Focus on wherever the browser supports it, and background blur lets someone take part without showing their home.

## Interaction and captions in the same meeting

Someone wants to type a word they cannot pronounce yet, show a CV or signal that they have something to say. Chat, raised hands, reactions and muted or away signals travel as [real-time data messages](https://docs.aws.amazon.com/chime-sdk/latest/dg/webrtc-media.html) beside the call, each carrying a topic and up to 2 KB of data to everyone in the meeting. Screen sharing uses the SDK&apos;s content share.

In the one-to-one room, either person can ask for live captions through [Amazon Chime SDK live transcription](https://docs.aws.amazon.com/chime-sdk/latest/dg/meeting-transcription.html). Captions start once the other agrees, and either can turn them off. Our sessions are often not in English, so Amazon Transcribe identifies the spoken language itself. It recognises the language; it does not translate. Captions show a learner how a word they just heard is spelt. After the call, the learner can save the transcript to their own account; otherwise TEEI keeps no copy.

## From meeting event to verified hour

Behind every room sit three lanes we keep apart. Access is our decision: our backend checks that a person belongs in that room at that time before Amazon Chime SDK is asked to let them in. Media is the SDK&apos;s: audio and video go straight between browsers and the media service, never through our servers. Attendance is the record: meeting events arrive on their own path, after the fact, so a slow record never slows a call.

![Architecture diagram with three separate lanes. Access runs from the browser through TEEI&apos;s backend, Amazon API Gateway and an AWS Lambda to Amazon Chime SDK, which grants the browser access to the meeting. Media flows directly between browsers and the Amazon Chime SDK media service, never through TEEI&apos;s backend or Lambda. Attendance flows from Amazon Chime SDK meeting events through Amazon EventBridge and a Lambda to TEEI&apos;s backend, which turns them into connected time, shared time and verified hours.](/images/blog/teei-amazon-chime-sdk/three-lanes.svg &quot;Three lanes, kept apart: access to a meeting, the media itself, and the record of who took part.&quot;)

Source: TEEI, drawn from the meeting and attendance services on our platform, 13 October 2026.

Amazon Chime SDK publishes an event when someone joins, leaves or drops and when a meeting ends. TEEI&apos;s rules take it from there. We combine each person&apos;s events into connected time and work out how long people were connected together. After the session, we settle it: a one-to-one session is completed when both people shared at least five minutes. Missing events make a session unknown, never a no-show, because a missing signal is not proof that someone stayed away. Hours come from measured time in the room, not from the length of the booking.

By 14 October 2026, 1,440 one-to-one sessions on the platform had been settled as completed this way: 109 in July, 398 in August, 670 in September and 263 in the first two weeks of October. Connected time shows that someone took part by the platform&apos;s rules; it does not measure attention or learning.

Counted in minutes instead of sessions, across every meeting in our account, one-to-one and group, Amazon Chime SDK recorded 142,536 attendee-minutes from 1 July to 13 October 2026. An attendee-minute is one person connected to a meeting for one minute. Use grew almost every week, from 215 in the first full week of July to 17,036 in the week from 21 September.

![Bar chart of Amazon Chime SDK attendee-minutes per week, Monday to Sunday, for the 14 full weeks from 6 July to 11 October 2026. Use rises almost every week, from 215 in the first week to a peak of 17,036 in the week of 21 September, and stands at 13,707 in the week of 5 October.](/images/blog/teei-amazon-chime-sdk/attendee-minutes-weekly.svg#wide,phone,fit &quot;Amazon Chime SDK attendee-minutes per full week, July to October 2026. With the days before the first full week and after the last, the total from 1 July to 13 October is 142,536.&quot;)

Source: TEEI, from AWS Cost Explorer, the daily usage of Amazon Chime SDK attendee-minutes across every meeting in our account, read on 15 October 2026 at 06:08 UTC.

## Evenhall: the company report

[Evenhall by TEEI](/partners/evenhall/) is the workspace companies use to take part in TEEI&apos;s programmes, and it has no video of its own. A company applies, and once TEEI has reviewed the application, its managers invite employees into TEEI Language, Skills Academy or TEEI Mentorship. Employees sign up on the programme platform and take part there, as conversation partners, mentors or instructors, in the same rooms as everyone else.

What Evenhall adds is the company&apos;s view: who took part, how much time they gave, and reports a manager can prepare for leadership. It reads that participation from the programme platforms in a nightly sync. For one-to-one sessions, an employee&apos;s verified hours are the shared time from sessions settled as completed; for a Skills Academy instructor, the measured time in the room, capped at the scheduled length. Where no measurement exists, such as a host&apos;s own time in a group session, Evenhall credits no hours rather than estimating them. The dashboards are TEEI&apos;s software; the hours in them start as meeting events.

## How it all connects

Put together, the parts form one logical journey:

**Evenhall → TEEI programmes → Amazon Chime SDK → recorded participation → programme history and reporting**

It is not one synchronous chain. The meeting happens live, the record is settled after the session, and Evenhall reads it overnight. No step waits for the next, and no dashboard sits between two people who are talking.

![Diagram. Four programme doors: TEEI Language, Skills Academy and TEEI Mentorship, which form one path, and TEEI Meetings. Evenhall invites employees into the programmes. The live conversations in all four run on one Amazon Chime SDK layer. Meeting events flow through Amazon EventBridge to attendance settled by TEEI and into programme history, and a dashed arrow returns verified hours to Evenhall nightly.](/images/blog/teei-amazon-chime-sdk/ecosystem.svg &quot;One layer under every programme: Amazon Chime SDK carries the live conversations, and the record of who took part flows on to programme histories and to Evenhall.&quot;)

Source: TEEI, drawn from our programme platform and Evenhall, 14 October 2026.

**The participant.** A learner from Ukraine books a time with a volunteer in TEEI Language, opens the room from their TEEI page, practises with captions on, and leaves. The meeting events become a completed session in their history. When they move on to a live Skills Academy class or to a mentor in TEEI Mentorship, each session is recorded the same way, and class attendance counts towards the class certificate.

**The company.** A manager invites an employee from Evenhall. The employee becomes a mentor in TEEI Mentorship and starts meeting a mentee. Each conversation runs on Amazon Chime SDK and is settled by the same rules as every other session. After the nightly sync, Evenhall shows the employee&apos;s verified hours, and the manager prepares a report. The company never sees the conversation, only the record that it took place.

## How Amazon Chime SDK connects to the rest of AWS

The sections above follow a session from the room to the report. This one is for readers who build on AWS: which services sit around each room, where they run, and why we set them up this way. Unless we say otherwise, it describes what production runs today.

![Architecture diagram in three bands. Outside AWS, the browser asks TEEI&apos;s backend on Cloudflare Workers to join. The backend sends an authenticated request to Amazon API Gateway in Europe (Ireland), where a Lambda authorizer checks it, and a meeting function in AWS Lambda calls the Amazon Chime SDK Meetings API in Europe (Frankfurt). After authorisation, the browser exchanges audio, video and data messages directly with the Amazon Chime SDK media service in Ireland, which calls Amazon Transcribe for captions. In Frankfurt, meeting events go to Amazon EventBridge. One rule sends them to a Lambda function that forwards them to TEEI&apos;s backend over HTTPS, with failures held in an Amazon SQS dead-letter queue and replayed every 15 minutes. A second rule sends them to a Lambda function that writes receipts to Amazon S3. An hourly canary and Amazon CloudWatch alarms watch the path.](/images/blog/teei-amazon-chime-sdk/aws-architecture.svg &quot;How Amazon Chime SDK connects to the rest of our AWS: the API and the media in Ireland, meeting control and events in Frankfurt.&quot;)

Source: TEEI, drawn from our AWS infrastructure code and checked against our AWS account, 14 October 2026.

Table: The AWS services around a TEEI room and what each one does. Grouped by the Region it runs in.

| AWS service | What it does for a room |
|---|---|
| Europe (Ireland): the entrance and the media | |
| Amazon API Gateway (HTTP API) | The one entrance from our backend into AWS, throttled and guarded by a Lambda authorizer |
| Amazon Chime SDK meetings, media | Hosts the meeting itself: audio, video, screen sharing and data messages between browsers |
| Amazon Transcribe | Live captions, called by Amazon Chime SDK itself |
| AWS X-Ray | Traces the authorizer and the meeting function |
| Europe (Frankfurt): meeting control and events | |
| Amazon Chime SDK meetings, control | Creates meetings and attendees, changes what an attendee may send, starts captions and publishes meeting events |
| Amazon EventBridge | Receives meeting events on the default event bus and routes them by two independent rules |
| Amazon SQS | Dead-letter queues for events that could not be delivered |
| Amazon S3 | A separate receipt of eight kinds of meeting event, kept for 30 days |
| In both Regions | |
| AWS Lambda | In Ireland, the authorizer and the meeting function; in Frankfurt, event forwarding, replay of failed events, receipts and an hourly canary |
| AWS Secrets Manager | The secrets that authenticate calls between our backend and AWS, in both directions |
| Amazon CloudWatch | Alarms on failed joins, rejected requests, failed deliveries and a silent event path |

### Two Regions, and why

Amazon Chime SDK splits a meeting into a control plane, the API that creates and changes meetings, and a media plane, where the meeting itself runs. The two do not have to be in the same Region. Europe (Ireland) hosts meeting media but has no meetings control endpoint, so our meeting function in Ireland calls the control API in Europe (Frankfurt) and asks for every meeting&apos;s media to be hosted in Ireland, close to our participants. Both Regions are in the EU ([Regions for Amazon Chime SDK meetings](https://docs.aws.amazon.com/chime-sdk/latest/dg/sdk-available-regions.html)).

The control Region decides more than where an API call lands. A meeting can only be updated in the control Region that created it, and its events are delivered in that Region as well ([choosing a control Region](https://docs.aws.amazon.com/chime-sdk/latest/dg/chime-sdk-meetings-regions.html)). That is why our whole attendance lane, from the EventBridge rule to the dead-letter queue, runs in Frankfurt, while the API our backend calls runs in Ireland. A separate test environment uses Europe (London) as its control Region, so test meetings and their events never mix with production&apos;s.

### Join, step by step

Pressing Join sets off six steps:

1. **Our backend decides.** The Cloudflare Worker checks that the person belongs to the booking, that the booking is inside its join window and, for a volunteer or mentor, that they are still approved. Loading the page never creates a meeting. A Join request does, and so can a warm-up job that prepares the meeting shortly before a session starts, if it has none yet.
2. **One meeting per booking.** Whichever comes first, a Join or the warm-up job, creates the meeting under a 60-second lease, so two people who press Join at the same moment cannot create two meetings. The meeting ID is stored with the booking, and every later Join, including a reconnect, reuses it. Amazon Chime SDK ends a meeting after five continuous minutes with nobody connected ([Amazon Chime SDK FAQ](https://docs.aws.amazon.com/chime-sdk/latest/dg/chime-sdk-faq.html)). If that has happened, the next Join creates a new meeting and our backend reconciles the booking with it.
3. **Short-lived, narrow access.** The backend grants short-lived access to one authorised meeting, either to join it or to host it. Server code chooses which, and nothing the browser sends can raise it.
4. **API Gateway and the authorizer.** The request reaches a throttled Amazon API Gateway HTTP API, where a separate authorizer validates it before the meeting function acts.
5. **The meeting function checks again.** An AWS Lambda function compares the request with what was granted: ending a meeting, removing an attendee and changing capabilities all need the host capability. Then it calls the Amazon Chime SDK Meetings API: GetMeeting to reuse a meeting, [CreateMeeting](https://docs.aws.amazon.com/chime-sdk/latest/APIReference/API_meeting-chime_CreateMeeting.html) if there is none, then CreateAttendee.
6. **The browser connects.** After authorisation, the browser connects directly to the meeting&apos;s media service in Ireland. No AWS credentials ever reach the browser.

Both functions run on Node.js 22 on Arm-based AWS Graviton processors, with AWS X-Ray tracing. One instance of each is kept warm with provisioned concurrency, so the first Join of the morning does not wait for a cold start.

The identifiers the meeting service receives carry no personal data. The external meeting ID is the booking&apos;s generated identifier, and the user ID is pseudonymous; the meeting function refuses any user ID that is not one.

The call that creates a meeting is short. This is the CreateMeeting request in our meeting function, with its comment shortened:

```ts
const created = await chime.send(
  new CreateMeetingCommand({
    ClientRequestToken: clientRequestToken ?? buildClientRequestToken(bookingId),
    ExternalMeetingId: bookingId.slice(0, 64),
    MediaRegion: mediaRegion,
    // Machine-learning echo reduction. It must be requested here, at meeting
    // creation: a client cannot opt into it later.
    MeetingFeatures: {
      Audio: { EchoReduction: &apos;AVAILABLE&apos; },
    },
  }),
);
```

Echo reduction is the one meeting feature we request. Everything else, from capabilities to captions, can change while the meeting runs.

Retries are split on purpose. Our backend makes up to four attempts at a failed call, with full-jitter backoff that starts at 400 milliseconds and is capped at 4 seconds, on network errors, throttling and server errors. The one call it never repeats is a fresh CreateMeeting: a request that timed out may still have created a meeting, and a second attempt could create another. Attendee calls are safe to repeat. Inside the function, the AWS SDK uses its adaptive retry mode with up to three attempts.

The same HTTP API is not only for meetings. Behind the same authorizer and the same kind of signed token, it serves more than twenty other routes for the programmes, from image uploads to Amazon S3 to features built on Amazon Bedrock. They are separate functions and take no part in a call.

### In the browser

The rooms use version 3.32.0 of the Amazon Chime SDK client library for JavaScript, loaded only when someone opens a room, so the rest of the platform never downloads it. The one-to-one room and the group room apply one shared tuning function before the meeting session is created, so the two cannot drift apart. With its comments removed, it reads:

```ts
export function applyWeakNetworkTuning(
  configuration: MeetingSessionConfiguration,
  logger: Logger,
): void {
  configuration.enableSVC = true;
  configuration.videoDownlinkBandwidthPolicy = weakNetworkDownlinkPolicy(logger);
  configuration.videoUplinkBandwidthPolicy = createChimeVideoUplinkPolicy(
    configuration.credentials?.attendeeId ?? &apos;&apos;,
    logger,
  );
  configuration.connectionTimeoutMs = CONNECTION_TIMEOUT_MS;
  configuration.reconnectTimeoutMs = RECONNECT_TIMEOUT_MS;
  configuration.reconnectLongBackOffMs = RECONNECT_LONG_BACKOFF_MS;
  configuration.connectionHealthPolicyConfiguration =
    weakNetworkConnectionHealthPolicy();
  configuration.keepLastFrameWhenPaused = true;
}
```

The values come from a measurement. On 7 September 2026 we looked at 934 production sessions. In a typical session, packet loss at its worst moments (the 90th percentile) was about 2 per cent; in one session in ten, it reached about 21 per cent or more. Round-trip time stayed healthy, at about 110 milliseconds even at those worst moments. Every setting was still at its default. This is what we changed:

Table: Session settings in our rooms, compared with the defaults in version 3.32.0 of the client library.

| Setting | Default | Ours | Why |
|---|---|---|---|
| Connection timeout | 15 s | **30 s** | A first connection on a congested mobile link needs longer, and the person sees a lobby that says it is connecting |
| Reconnect window | 2 min | **5 min** | Power cuts and tower handovers often outlast two minutes |
| Longest reconnect backoff | 5 s | **8 s** | Fewer attempts against a link that is down |
| Unhealthy-connection threshold | 25 | **40** | A full reconnect tears down media that may still be carrying the conversation |
| Wait before acting on poor health | 10 s | **20 s** | Ride out a short drop instead of reconnecting |
| Missed pongs before reconnecting | 4 | **6** | The same reason |
| Keep the last frame when video pauses | Off | **On** | A frozen picture instead of a black box during a reconnect |

Scalable video coding is on, and the downlink policy is set explicitly to the [priority-based policy](https://aws.github.io/amazon-chime-sdk-js/modules/prioritybased_downlink_policy.html), configured to react at once to a network change and to recover slowly. iPhone browsers cannot use scalable video coding, and without the explicit policy they would fall back to one that does not adapt at all. The audio profile is stated rather than inherited: full-band mono speech at 40 kbit/s with redundancy, which in heavy loss can spend up to three times that. Redundancy was already the default; stating it means no later change can switch it off in passing.

One setting we left alone on purpose. The attendee presence timeout stays at zero, which means the session does not wait for presence at all. Raising it would not make a slow join more patient: it would add a hard gate that fails the join when presence is slow, and on a lossy uplink, presence is exactly what is slow.

Voice Focus and background blur need a cross-origin isolated page, so the room pages, and only those, are served with the headers that grant it. Safari does not support the form of isolation we use, so on an iPhone the room works without Voice Focus and blur. In group rooms, where a lost message would leave a person nameless or the room without its host, our sender tries a failed name or host announcement again after 400 milliseconds, 1.2 seconds and 3 seconds.

### Captions without the audio leaving AWS

When both people in a one-to-one call agree to captions, our backend checks that the meeting is the booking&apos;s current one and asks the meeting function to start transcription. The function calls [StartMeetingTranscription](https://docs.aws.amazon.com/chime-sdk/latest/APIReference/API_meeting-chime_StartMeetingTranscription.html) with these settings for Amazon Transcribe:

- the transcription Region set to the meeting&apos;s own media Region, Ireland;
- automatic language identification, with English as the preferred language;
- [partial-results stabilisation](https://docs.aws.amazon.com/chime-sdk/latest/dg/transcription-options.html) at high stability, so a line stops changing quickly.

From there, Amazon Chime SDK does the work. It selects the two most active speakers, sends their audio to Amazon Transcribe on separate channels of one stream without the audio leaving the AWS network, and sends the speaker-attributed lines straight to every participant as data messages ([how live transcription works](https://docs.aws.amazon.com/chime-sdk/latest/dg/meeting-transcription.html)). The live transcript never passes through our meeting function or our backend; it reaches us only if the learner saves it after the call. Our function needs no Amazon Transcribe permissions either, because Amazon Chime SDK calls the service through its own service-linked role. If captions fail to start, the call simply goes on without them.

### From EventBridge to the record

Amazon Chime SDK publishes [meeting events](https://docs.aws.amazon.com/chime-sdk/latest/dg/using-events.html) to the default [Amazon EventBridge](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-service-event.html) event bus in Frankfurt. Two rules listen there, and neither depends on the other. This is the attendance rule&apos;s event pattern, as it stands in our account:

```json
{
  &quot;detail-type&quot;: [&quot;Chime Meeting State Change&quot;],
  &quot;source&quot;: [&quot;aws.chime&quot;],
  &quot;detail&quot;: {
    &quot;eventType&quot;: [
      &quot;chime:AttendeeJoined&quot;,
      &quot;chime:AttendeeLeft&quot;,
      &quot;chime:AttendeeDropped&quot;,
      &quot;chime:MeetingEnded&quot;,
      &quot;chime:AttendeeContentJoined&quot;,
      &quot;chime:AttendeeContentLeft&quot;
    ]
  }
}
```

Its target is a small Lambda function. It forwards six fields (meeting ID, external meeting ID, external user ID, attendee ID, event type and time) to our backend over HTTPS with a bearer secret, and it fails on any answer other than success. Retries come in two layers. If EventBridge cannot deliver an event to the function, the rule&apos;s target tries again up to 10 times over six hours. If the function runs and the forward fails, Lambda&apos;s asynchronous retry tries it twice more within an hour. An event that still fails on either layer lands in the same encrypted Amazon SQS dead-letter queue, which keeps messages for 14 days. In a production rehearsal on 8 July 2026, join and leave events reached our database about three seconds after the real joins.

Our backend stores each event exactly once. A unique key over the booking, the meeting, the attendee, the event type and the timestamp turns a redelivered event into a no-op that still answers success. A database failure answers with an error instead, so the event is retried rather than lost. Screen-sharing events arrive too and are acknowledged without being stored, since attendance does not use them.

Two small functions keep the lane honest:

- **Replay.** Every 15 minutes a drain function takes up to 100 messages from the dead-letter queue and replays them through the forwarding function. It deletes a message only after the replay succeeds, and stops after three failures in a row. We added it after 7 October 2026, when our backend answered with errors for about three minutes, longer than the forward&apos;s retries lasted, and one meeting-ended event sat in the queue for three days until we replayed it by hand.
- **A canary.** Every hour a canary confirms that the real rule exists, is enabled and has a target. It then sends a synthetic event to our backend through the same entrance, authentication and database read as a real one.

The second rule is independent of the first. It takes eight event types: joins, leaves, drops and meeting-ended, but not the two screen-sharing events, plus meeting-started, attendee-added and video start and stop. It sends them to a separate function that writes a receipt of each event to Amazon S3. Each receipt is named after a hash of the event and written with a conditional put, so a redelivered event never creates a second object. The function holds no database credentials and never calls our backend, and the receipts carry only the pseudonymous user ID. They are kept for 30 days, the window in which a settled session can be checked against the events behind it.

### Settlement

Settlement runs on our side, every 15 minutes, on Cloudflare Workers. A one-to-one session is settled from 30 minutes after its booked end, once the room is empty; if the two are still talking, settlement waits for them, for up to four hours past the booked end. A group session is settled 30 minutes after it ends. For each person, the job takes the union of their join and leave intervals, where a drop also closes an interval and the end of the meeting closes any interval still open. It then intersects two people&apos;s intervals to find the time they were connected together. A one-to-one session ends in one of four outcomes:

- **Completed:** the two shared at least five minutes, and the meeting-ended event has arrived.
- **No-show:** exactly one person verifiably attended. Because late meeting events can still arrive, this is decided no earlier than six hours after the booked end; until then the session waits as unknown.
- **Not held:** nobody opened the room, or no meeting was ever created.
- **Unknown:** events are missing, an interval never closed, or both people were in the room but never at the same time.

### Watching the path

Our meeting function catches every Amazon Chime SDK error and answers with a clear failure, so the Lambda error metric never sees a failed join. Instead, a CloudWatch metric filter counts the failure lines the function writes for production joins, with two alarms on it: one for any failed join, and one for joins failing in two of three five-minute periods, which points to a control-plane or permissions problem rather than one bad call. Other alarms watch the authorizer for errors and for a spike in rejected tokens, the API for server errors, and both functions for requests that spill past their warm capacity. In Frankfurt, alarms fire on forwarding errors, on any message in the dead-letter queue, on a failing canary or one that has not run for three hours, and on a drain that fails or leaves a message older than an hour.

Day by day, the path follows the sessions. In the 30 days to 13 October the meeting function answered 5,839 calls, the forwarder passed 10,068 meeting events to the record, and the receipts function wrote 23,234 receipts. The meeting function and the receipts recorded no errors. 33 forwarding attempts failed, on eight days, and each was tried again; 18 of them fell on 7 October, the three minutes of backend errors that led us to add the replay.

![Three small line charts of the meeting path per day, 14 September to 13 October 2026, each on its own scale. Calls to the meeting function peak at 682 on 13 October, meeting events forwarded to the record at 943 on 22 September, and receipts written to Amazon S3 at 2,323 on 22 September. Dots under the forwarding chart mark the eight days with failed attempts, each tried again.](/images/blog/teei-amazon-chime-sdk/meeting-path-daily.svg#phone,fit &quot;The meeting path per day: calls to the meeting function, meeting events forwarded to the record, and receipts written to Amazon S3. Each panel has its own scale, with the busiest day printed at its point, and a dot marks a day with failed attempts.&quot;)

Source: TEEI, from Amazon CloudWatch, the Invocations and Errors metrics of the three functions per calendar day in UTC, read on 15 October 2026 at 06:12 UTC.

## The test room: every room, opened in its roles

The test environment we mentioned under [Two Regions, and why](#two-regions-and-why), with Europe (London) as its control Region, has a page of its own. It lists every room we run on Amazon Chime SDK, from the booked one-to-one session and the Speaking Café, the Speaking Club format for open conversation practice, to TEEI Meetings and Skills Academy&apos;s webinars: 25 rows on 16 October 2026. Nothing on it is typed in by hand. The page is computed when it loads, from the same code the rooms run: who may speak, who holds the host tools, whether captions are offered, how many video tiles are shown, and where our settings differ from the SDK&apos;s defaults, each with a link to the line of code that decides it.

Every room below opens with one press, signed in as one of the demo personas: the sample learners, volunteers and hosts from TEEI&apos;s public tours. The environment has its own database, so no participant&apos;s data is in it. The rooms are open to anyone reading this.


&lt;figure&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;&lt;/span&gt;Test room · London · demo personas only&lt;/span&gt;&lt;span&gt;Skills Academy webinar, as a listener&lt;/span&gt;&lt;/div&gt;&lt;div&gt;&lt;img src=&quot;/images/blog/teei-aws/test-room-stage.webp&quot; width=&quot;1440&quot; height=&quot;1180&quot; alt=&quot;A Skills Academy webinar in the TEEI test room, seen by a listener. On the left the room, hosted by Fatima Al-Sayed, with a Join the session button. On the right the AWS trace panel with its tabs Join, Media, Capabilities, Captions, Events, Settle and Fault path, open at Join, which lists each step of the join with its time.&quot; /&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;3&lt;/span&gt;&lt;span&gt;4&lt;/span&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;p&gt;One press&lt;/p&gt;&lt;p&gt;You are in the room as a listener, with the AWS trace open beside it.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=skills-webinar&amp;amp;as=participant-1&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Enter the webinar as a listener &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=skills-webinar&amp;amp;as=host&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;as the host&lt;/a&gt;&lt;/p&gt;&lt;p&gt;Open the host in one tab and a listener in another to see both sides.&lt;/p&gt;&lt;/div&gt;&lt;div&gt;&lt;p&gt;The six steps of a join &lt;span&gt;measured 15 October 2026&lt;/span&gt;&lt;/p&gt;&lt;ol&gt;&lt;li&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;Who may enter&lt;/span&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;&lt;span&gt;0 ms&lt;/span&gt;&lt;/li&gt;&lt;li&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;Meeting lease&lt;/span&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;&lt;span&gt;95 ms&lt;/span&gt;&lt;/li&gt;&lt;li&gt;&lt;span&gt;3&lt;/span&gt;&lt;span&gt;Signed token&lt;/span&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;&lt;span&gt;0 ms&lt;/span&gt;&lt;/li&gt;&lt;li&gt;&lt;span&gt;4&lt;/span&gt;&lt;span&gt;API Gateway&lt;/span&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;&lt;span&gt;125 ms&lt;/span&gt;&lt;/li&gt;&lt;li&gt;&lt;span&gt;5&lt;/span&gt;&lt;span&gt;Meeting Lambda&lt;/span&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;&lt;span&gt;52 ms&lt;/span&gt;&lt;/li&gt;&lt;li&gt;&lt;span&gt;6&lt;/span&gt;&lt;span&gt;Media&lt;/span&gt;&lt;span&gt;your browser, straight to Amazon Chime SDK&lt;/span&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;/div&gt;&lt;ol&gt;&lt;li&gt;&lt;b&gt;1&lt;/b&gt;&lt;span&gt;The room. The host is Fatima Al-Sayed, a demo persona; you join muted, as a listener.&lt;/span&gt;&lt;/li&gt;&lt;li&gt;&lt;b&gt;2&lt;/b&gt;&lt;span&gt;One press joins. Step one, who may enter, is decided before anything reaches AWS.&lt;/span&gt;&lt;/li&gt;&lt;li&gt;&lt;b&gt;3&lt;/b&gt;&lt;span&gt;The AWS trace: Join, Media, Capabilities, Captions, Events, Settle and Fault path.&lt;/span&gt;&lt;/li&gt;&lt;li&gt;&lt;b&gt;4&lt;/b&gt;&lt;span&gt;The join token, decoded. The Worker removes the account id before it writes the trace.&lt;/span&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/figure&gt;
&lt;section&gt;&lt;div&gt;&lt;p&gt;Twelve rooms, one press each&lt;/p&gt;&lt;a href=&quot;/partners/aws/chime/&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;All 25 rows, with the code behind each &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;/div&gt;&lt;p&gt;&lt;span&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;everyone speaks&lt;/span&gt;&lt;span&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;the host presents&lt;/span&gt;&lt;span&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;listeners are invited up&lt;/span&gt;&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;p&gt;TEEI Language&lt;/p&gt;&lt;p&gt;Booked one-to-one&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;Everyone speaks&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=booked-1to1&amp;amp;as=learner&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Learner &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=booked-1to1&amp;amp;as=volunteer&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Volunteer&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;TEEI Language&lt;/p&gt;&lt;p&gt;Speaking Café&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;Everyone speaks&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=speaking-cafe&amp;amp;as=host&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Host &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=speaking-cafe&amp;amp;as=participant-1&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Learner&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;TEEI Language&lt;/p&gt;&lt;p&gt;Workshop&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;The host presents &lt;span&gt;held by the room&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=workshop&amp;amp;as=host&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Host &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=workshop&amp;amp;as=participant-1&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Learner&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;TEEI Language&lt;/p&gt;&lt;p&gt;Peer practice&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;Everyone speaks&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=peer-practice&amp;amp;as=participant-1&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Learner 1 &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=peer-practice&amp;amp;as=participant-2&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Learner 2&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;TEEI Language&lt;/p&gt;&lt;p&gt;Guest in a call&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;Everyone speaks&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=guest-call&amp;amp;as=volunteer&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Volunteer &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=guest-call&amp;amp;as=guest&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Guest&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;TEEI Mentorship&lt;/p&gt;&lt;p&gt;Booked mentoring&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;Everyone speaks&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=booked-mentoring&amp;amp;as=mentor&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Mentor &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=booked-mentoring&amp;amp;as=mentee&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Mentee&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;TEEI Mentorship&lt;/p&gt;&lt;p&gt;Group mentoring&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;Everyone speaks&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=group-mentoring&amp;amp;as=host&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Host &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=group-mentoring&amp;amp;as=participant-1&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Participant&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;Skills Academy&lt;/p&gt;&lt;p&gt;Webinar&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;Invited up to speak &lt;span&gt;Chime enforces&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=skills-webinar&amp;amp;as=host&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Host &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=skills-webinar&amp;amp;as=participant-1&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Participant&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;Skills Academy&lt;/p&gt;&lt;p&gt;Class&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;Everyone speaks&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=skills-class&amp;amp;as=host&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Host &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=skills-class&amp;amp;as=participant-1&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Participant&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;Skills Academy&lt;/p&gt;&lt;p&gt;Expert Q&amp;amp;A&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;Invited up to speak &lt;span&gt;Chime enforces&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=expert-qa&amp;amp;as=host&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Host &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=expert-qa&amp;amp;as=participant-1&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Participant&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;TEEI Meetings&lt;/p&gt;&lt;p&gt;Meeting&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;Everyone speaks&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=meeting&amp;amp;as=host&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Host &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=meeting&amp;amp;as=guest&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Guest&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;TEEI Meetings&lt;/p&gt;&lt;p&gt;Webinar&lt;/p&gt;&lt;p&gt;&lt;span&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;i&gt;&lt;/i&gt;&lt;/span&gt;Invited up to speak &lt;span&gt;Chime enforces&lt;/span&gt;&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;/partners/aws/chime/?room=meeting-webinar&amp;amp;as=host&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Host &lt;span&gt;↗&lt;/span&gt;&lt;/a&gt;&lt;a href=&quot;/partners/aws/chime/?room=meeting-webinar&amp;amp;as=listener&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Listener&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;&lt;span&gt;Swipe for all twelve rooms&lt;/span&gt;&lt;span&gt;→&lt;/span&gt;&lt;/p&gt;&lt;p&gt;Each button opens the room in a new tab, in that role. A running room is reused; if its demo personas are all in other rooms, you land on the overview and press again a few minutes later.&lt;/p&gt;&lt;/section&gt;

Once you are in, a panel beside the room shows what happened on AWS. Its tabs follow the path this article has taken: [the join](#join-step-by-step), [the settings in the browser](#in-the-browser), the [webinar roles](#one-room-layer-several-programmes), [captions](#captions-without-the-audio-leaving-aws), [the event lane](#from-eventbridge-to-the-record) with its dead-letter queue and replay, and [settlement](#settlement). A group room or a meeting lets you in as it opens; a one-to-one call waits for you to press Join:

- **Join**: each step of the join with its timing, the AWS X-Ray trace id, and which function answered.
- **Media**: packet loss, round-trip time, the video layer being received, the audio profile, and whether Voice Focus is on.
- **Capabilities**: in a Skills Academy webinar, the `UpdateAttendeeCapabilities` call made when the host invites a listener to speak, as sent and as answered. Attendee capabilities are what a tile marked *Chime enforces* stands for.
- **Captions**: Amazon Transcribe captions in a one-to-one session, with a count of the caption bytes that passed through our servers. The count stays at zero, because the captions reach the browser from Amazon Chime SDK.
- **Events**: every Amazon EventBridge event for the meeting, the fields the forwarding function sends, what our backend did with it, and its receipt in Amazon S3 once the second rule has written it.
- **Settle**: the attendance settlement, run for this one room at once instead of after the usual wait, with its outcome and the time both people were connected.
- **Fault path**: in the test environment only, our backend can be set to refuse the next event. A reader then watches that event refused three times, land in the dead-letter queue, and come back stored when the queue is drained.

&lt;div&gt;&lt;p&gt;Inside the trace&lt;/p&gt;&lt;div&gt;&lt;figure&gt;&lt;div&gt;&lt;img src=&quot;/images/blog/teei-aws/test-room-trace-join.webp&quot; width=&quot;432&quot; height=&quot;1180&quot; alt=&quot;The Join tab of the trace: six steps from the click to media, each with its time, from who may enter to the meeting Lambda.&quot; /&gt;&lt;/div&gt;&lt;figcaption&gt;&lt;b&gt;Join.&lt;/b&gt; Six steps from the click to media, each with its time and its answer.&lt;/figcaption&gt;&lt;/figure&gt;&lt;figure&gt;&lt;div&gt;&lt;img src=&quot;/images/blog/teei-aws/test-room-trace-capabilities.webp&quot; width=&quot;432&quot; height=&quot;900&quot; alt=&quot;The Capabilities tab: the UpdateAttendeeCapabilities call as sent and as answered, HTTP 200 in 133 ms.&quot; /&gt;&lt;/div&gt;&lt;figcaption&gt;&lt;b&gt;Capabilities.&lt;/b&gt; The UpdateAttendeeCapabilities call when a host invites a listener up, as sent and as answered: HTTP 200 in 133 ms.&lt;/figcaption&gt;&lt;/figure&gt;&lt;figure&gt;&lt;div&gt;&lt;img src=&quot;/images/blog/teei-aws/test-room-trace-fault.webp&quot; width=&quot;432&quot; height=&quot;470&quot; alt=&quot;The Fault path tab: an Arm switch, the dead-letter queue count and a Drain now button.&quot; /&gt;&lt;/div&gt;&lt;figcaption&gt;&lt;b&gt;Fault path.&lt;/b&gt; Armed, the ingest refuses the next event three times; it lands in the dead-letter queue, and Drain replays it.&lt;/figcaption&gt;&lt;/figure&gt;&lt;/div&gt;&lt;p&gt;Source: TEEI, captured in the London test environment on 15 October 2026, with demo personas only. Identifiers are removed from the images.&lt;/p&gt;&lt;/div&gt;

On 15 October 2026 a scripted walk with two browsers joined a Skills Academy webinar in the test room as host and listener, and passed all 23 of its steps. The listener raised a hand, the host invited them to speak, and the capability call answered with HTTP 200 in 115 ms. The meeting&apos;s events arrived with their receipts in Amazon S3. An earlier walk the same day ran the fault path end to end: three refusals, the event in the dead-letter queue, and the event stored after the drain.

## What this means for TEEI&apos;s work

TEEI&apos;s work reaches 187+ countries, and our participants and volunteers are spread across languages and time zones, on very different devices and connections. Building on one SDK lets us meet that with one set of decisions instead of one per programme. When we improve speech on a weak network, every Speaking Club, mentoring session and class benefits at once. When a format needs firmer roles, attendee capabilities control who speaks without a media server of our own. When a company asks what its employees contributed, the answer starts from meeting events rather than from a calendar.

Amazon Chime SDK is the layer every programme shares. It carries the live conversations in TEEI Language, Skills Academy, TEEI Mentorship and TEEI Meetings, and it produces the events that programme histories and Evenhall reports are built on. Everything around it, from matching to class certificates, is ours to shape.</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Amazon Chime SDK</category><category>AWS</category><category>Verified hours</category><category>Evenhall</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>How Algolia Powers Multilingual Search at TEEI</title><link>https://theeducationalequalityinstitute.org/blog/how-teei-uses-algolia-to-find-what-people-meant-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/how-teei-uses-algolia-to-find-what-people-meant-en/</guid><description>With support from Algolia for Open Source, TEEI helps people find help answers and job titles in four languages, even when the spelling is imperfect.</description><pubDate>Tue, 13 Oct 2026 00:00:00 GMT</pubDate><content:encoded>At TEEI, finding the right answer should not depend on perfect spelling or on knowing the official name of a job. With support from Algolia for Open Source,[^1] we use Algolia across our website and in [TEEI Mentorship](/programmes/education/teei-mentorship/) so that people can find help answers, blog posts and occupations with the words they actually use.

Search should work when someone is using a second language, typing on a phone or looking for help just before a call. They type &quot;ukrane&quot; when they mean Ukraine, or &quot;sykeplier&quot; when they mean sykepleier, the Norwegian word for nurse. They type &quot;програміст&quot; (programmer) in Ukrainian, or &quot;не працює камера&quot; (my camera is not working) a few minutes before a call. Algolia finds what they meant.

In the TEEI Mentorship job picker, that means searching 3,039 occupations in English, Norwegian, Polish and Ukrainian. In the TEEI Mentorship help centre, it means finding the answer about a camera or microphone from a search typed in Ukrainian. This article shows how those searches work, how we keep them private and what we measured on 13 October 2026. The engineering detail is in an appendix at the end.

**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.

## Key facts

- Algolia answers four search boxes: blog search and help on our website, the TEEI Mentorship help centre in English and Ukrainian, and the job picker in TEEI Mentorship.
- The job picker searches 3,039 occupations in four languages. Before it went live, a fixed set of 83 test searches, typed the way people type them, all passed in development, staging and production.
- Every search goes to Algolia from our own servers on Cloudflare, not from the reader&apos;s browser. We send the words typed, any filters and the name of the box, never an account, an IP address or browser details.
- Our own logs record which box was used, which engine answered, how many results came back and how long it took. Never the words.
- We timed 150 live searches from Oslo on 13 October 2026, 50 to each of the three public boxes. The median time to an answer, our own servers included, was 77 ms on blog search, 69 ms on help and 201 ms in the TEEI Mentorship help centre. Algolia answered all 150.

## Where Algolia search runs today

Table: The four search boxes Algolia answers, as of 13 October 2026.

| Search box | Where | What it searches |
|---|---|---|
| Blog search | [Our blog](/blog/search/) | Blog posts |
| Help and FAQ | [Our help pages](/help/) | Answers about taking part, volunteering and our programmes |
| TEEI Mentorship help centre | [mentorship.theeducationalequalityinstitute.org/help](https://mentorship.theeducationalequalityinstitute.org/help) | Help answers for mentees and mentors, in English and Ukrainian |
| Job picker | The TEEI Mentorship application and mentee goals, behind sign-in | The European list of 3,039 occupations (ESCO), in English, Norwegian, Polish and Ukrainian[^2] |

The website and the help centre share one index. It holds our blog posts and the help answers from our website, [TEEI Language](/programmes/education/teei-language/) and TEEI Mentorship, in English, Norwegian, Polish and Ukrainian. One copy of each answer means the help centre and the website can never disagree about what it says. The job picker has an index of its own.

![Two phones showing TEEI Mentorship in Ukrainian. On the left the job picker, where програміст lists software developer first. On the right the help centre, where не працює камера finds the answer about a camera or microphone that is not working. Both show the Search by Algolia logo.](/images/blog/how-teei-uses-algolia/two-phones-ukrainian-1.webp#phones,390x844 &quot;Two of the boxes in Ukrainian. Left, the job picker: “програміст” puts the software developer first, above a CNC programmer. Right, the TEEI Mentorship help centre: “не працює камера” finds “My camera or microphone is not working”&quot;)
![](/images/blog/how-teei-uses-algolia/two-phones-ukrainian-2.webp#phones,390x844)

Source: the TEEI Mentorship help centre in production, and the job picker on a test page that renders the same component and searches the same occupation list, captured 18 October 2026.

## One index, four languages

The job picker is where Algolia matters most. A mentor or mentee applying to TEEI Mentorship chooses their job from ESCO, the European Union&apos;s classification of occupations. The version we use lists 3,039 of them. We index each occupation once, with its names in English, Norwegian, Polish and Ukrainian. For common jobs we add our own names, where the official name is not what people type.

Algolia&apos;s typo tolerance does much of the work without any setting: &quot;sykeplier&quot;, one letter short, still matches sykepleier. The settings decide which of the matches comes first. These are the index settings, unabridged:

```js
export const SETTINGS = {
  searchableAttributes: [`${names(&apos;name&apos;)},${names(&apos;own&apos;)}`, names(&apos;alt&apos;)],
  indexLanguages: LANGUAGES,
  queryLanguages: LANGUAGES,
  ignorePlurals: true,
  decompoundedAttributes: { no: [&apos;name_no&apos;, &apos;own_no&apos;, &apos;alt_no&apos;] },
  attributesForFaceting: [&apos;filterOnly(isco3)&apos;],
  customRanking: [&apos;desc(common)&apos;, &apos;asc(nameWords)&apos;],
  attributesToRetrieve: [&apos;code&apos;, &apos;isco3&apos;, ...LANGUAGES.map((lang) =&amp;gt; `name_${lang}`)],
  attributesToHighlight: [],
  hitsPerPage: 6,
};
```

- **Official names and our own names rank level.** The first entry puts ESCO&apos;s preferred names and our own names at the same level in all four languages, and ESCO&apos;s other names come after. We measured why on 10 October 2026. With our names one level lower, a Ukrainian &quot;програміст&quot; typed in full lost to a CNC programmer, whose official Ukrainian name happens to contain the word.
- **Algolia splits Norwegian compound words**, so &quot;sykepleier&quot; also finds &quot;spesialsykepleier&quot;.[^3]
- **Algolia&apos;s custom ranking settles ties.** When Algolia&apos;s own criteria tie, a job with our own names ranks first, and a shorter name breaks the ties that remain.[^4] A misspelt &quot;sykeplier&quot; therefore puts the general nurse first, above the nine Norwegian compounds that also end in -sykepleier.
- **Plurals count as the same word** in all four languages.
- **Only what the picker shows comes back**: the occupation code, its job family and its four names, six results at a time.

For someone who trained as a nurse and is now looking for a mentor in Norway, this means the job they trained for comes first, even when the word comes out misspelt.

![The TEEI Mentorship job picker with the misspelt Norwegian word sykeplier typed in. The first result is nurse responsible for general care, sykepleier, followed by other nursing jobs. The Search by Algolia logo is under the list.](/images/blog/how-teei-uses-algolia/job-picker-sykeplier.webp#screen,600x556 &quot;The job picker with the misspelt Norwegian “sykeplier”. The general nurse comes first.&quot;)

Source: the TEEI Mentorship job picker on a test page that renders the same component and searches the same occupation list, because the picker itself sits behind sign-in, captured 13 October 2026.

Two checks stand in front of the live index. Before anything is written, the script that builds the index refuses a record over 10 KB. It also refuses any of our own names that does not match an ESCO occupation. And a fixed set of 83 searches, the words people type for common jobs in all four languages, runs against the index in each environment. A search passes when the right job comes first, or, for a word that fits several jobs equally, such as &quot;teacher&quot;, when it is among the six shown. Before the job picker went live, all 83 passed in development, staging and production.

## Combining Algolia&apos;s retrieval with TEEI&apos;s help-centre ranking

The TEEI Mentorship help centre had its own ranking before it moved to Algolia: a word in an answer&apos;s question counts most, then its keywords, then its text. We wanted to keep that order and add what Algolia does well, so each part got one job.

Algolia retrieves. It returns the answers that contain the reader&apos;s words, misspellings and half-typed words included, in the reader&apos;s language, as identifiers only. The help centre then orders them with the same ranker it uses on its own, and shows them from its own catalogue.

Algolia&apos;s ranking can be tuned, and we could have tuned it to the same order. We chose one ranker for both paths instead, after a test on 8 October 2026 showed the two orders differing for &quot;cancel session&quot;. Readers get one stable order, while Algolia adds typo tolerance, prefix matching and multilingual retrieval.

## One search, end to end

![Diagram of one search. A reader&apos;s browser sends the words typed to the TEEI Worker on Cloudflare. The Worker asks Algolia with the query and filters only. If Algolia has not answered within 1.5 seconds, our own ranker answers instead. Either way the results, posts, help answers or jobs, go back to the page.](/images/blog/how-teei-uses-algolia/search-request-path.svg &quot;The path of one search. The blue path is the normal one; the dashed path is the fallback.&quot;)

Source: TEEI, drawn from the search code on our website, 13 October 2026.

1. A reader types &quot;ukrane&quot; into blog search. Their browser sends the words to our Worker, the server code that runs on Cloudflare in the location nearest to them.
2. The Worker sends Algolia the query, any filters and the name of the box, with a key that can only search. The reader&apos;s IP address and account stay with our servers.
3. Algolia answers with the matching posts, typo included: &quot;ukrane&quot; finds our Together for Ukraine posts.
4. If Algolia has not answered within 1.5 seconds, or answers with an error, the Worker answers from the site&apos;s own search instead.
5. The page shows the results, and the Worker writes one log line: the box, the engine that answered, the number of results and the time taken.

The function that makes the call is in the appendix.

## Private by design

- **Searches leave from our servers.** Each search travels from our Worker to Algolia. We do not forward the reader&apos;s IP address, account or browser details.
- **Algolia receives the words.** A search is whatever the person typed, and Algolia needs it to answer. Algolia&apos;s own request logs include the search itself, as its documentation describes.[^5]
- **We never log what people type.** Our log line holds the box, the engine, the number of results and the time taken. When Algolia does not answer, the line says why, and still not what was searched.
- **Every key opens one door.** The servers hold keys that can only search. In TEEI Mentorship each environment has its own key, derived from a parent key and restricted to that environment&apos;s indexes.[^6] We measured each one on 11 October 2026 before installing it. It searched its own index, and Algolia refused it on another environment&apos;s index and on any write. A derived key that leaked would be revoked by replacing the parent key.
- **Only public text is indexed.** Blog posts, help answers and the European occupation list. No person&apos;s profile is in any index, and matching mentors with mentees never runs in Algolia.

## Keeping search available with a fallback

If Algolia takes longer than 1.5 seconds or answers with an error, our own search answers from the same content, so the reader sees results either way. The website has one switch for its two boxes, and each box in TEEI Mentorship has its own, kept in the database so a box can change engine without a release. Blog search also works without JavaScript: the search page is an ordinary web form, and the search runs on the server either way.

## What we measured

On 13 October 2026, at about 06:30 UTC, we sent 50 searches to each of the three public boxes from Oslo, 150 in all, one at a time and 150 ms apart. The job picker sits behind sign-in and was not timed. Each box got ten different searches, misspellings and Ukrainian included, five times each. Each time runs from sending the request to having read the answer, so it includes our own servers. Cloudflare answered from its Oslo location. Every answer says which engine produced it, and Algolia answered all 150.

Table: Time from request to answer for 50 live searches to each public box, 13 October 2026.

| Search box | Median {bar} | 95th percentile {bar} | Slowest {bar} | Answered by Algolia |
|---|---|---|---|---|
| Blog search | 77 ms | 98 ms | 272 ms | 50 of 50 |
| Help and FAQ | 69 ms | 82 ms | 90 ms | 50 of 50 |
| TEEI Mentorship help centre | 201 ms | 376 ms | 470 ms | 50 of 50 |

The help centre does more per search than the website. It checks which programme and language the request belongs to, and asks Algolia for up to 200 candidate answers to rank itself. We have not measured how much of the extra time each of those steps takes.

Relevance is measured separately from speed. For the job picker, the 83 test searches described above all passed in development, staging and production. On the same day the website index held 844 records, 59 blog posts and 785 help answers, and the occupation index held 3,039.

## What we learned

- **Give each engine one job.** Algolia retrieves and the help centre&apos;s ranker orders, so readers see one stable order and still get everything Algolia finds. Two engines that each ranked would have disagreed in front of a reader, as our test on 8 October 2026 showed.
- **Put everyday words level with the official ones.** Official names are correct and often not what people type. One level of difference in the settings decided whether &quot;програміст&quot; found a programmer.
- **Make the fallback visible somewhere.** The reader gets results either way, so an Algolia outage would never show on the page. The log line is the one place it shows: it names the engine that answered and, when that was not Algolia, the reason.
- **Prove each key before you install it.** Search its own index, then try another environment&apos;s index and a write, and expect a refusal on both.

## What comes next

- **The TEEI Language help centre**, once a release frees the space it needs (see the appendix).
- **Class search in the Skills Academy**, the catalogue of live classes in TEEI Mentorship. It is built and switched off, and goes live with its release.

## Thank you, Algolia

Through Algolia for Open Source, Algolia supports the search behind everything in this article. We are grateful for that support: it helps TEEI make programme information, help answers and occupation names easier to find for people who search in their second or third language.

The examples above show what that support looks like in practice: a misspelt Norwegian job title finding the general nurse, the Ukrainian word for programmer finding the software developer, and a Ukrainian question about a camera finding the right help answer. Thank you, Algolia, for supporting this work.

## Verify this

- Search the blog for &quot;ukrane&quot;: [theeducationalequalityinstitute.org/blog/search/?q=ukrane](/blog/search/?q=ukrane).
- Search [our help pages](/help/) with a misspelling, such as &quot;volunter&quot;.
- Search the [TEEI Mentorship help centre](https://mentorship.theeducationalequalityinstitute.org/help) in English or Ukrainian.
- If you are building with TEEI, [talk to us](/partners/technology/) and we will walk you through the code.

## Appendix: engineering notes

For developers who want the detail behind the sections above.

### The function that makes the call

This is the function on our website that sends one search to Algolia. TEEI Mentorship sends the same request. One line is shown as a comment: the line that reads the answer and checks it against a schema.

```ts
/** One search. Null means &quot;answer from the site&apos;s own search&quot;: the reason is logged here. */
export async function algoliaSearch(
  config: AlgoliaConfig,
  params: AlgoliaQuery,
  fetcher: FetchLike = (input, init) =&amp;gt; fetch(input, init),
  timeoutMs: number = ALGOLIA_TIMEOUT_MS,
): Promise {
  const { surface, ...search } = params;
  try {
    const res = await fetcher(algoliaQueryUrl(config), {
      method: &apos;POST&apos;,
      headers: {
        &apos;Content-Type&apos;: &apos;application/json&apos;,
        &apos;X-Algolia-Application-Id&apos;: config.appId,
        &apos;X-Algolia-API-Key&apos;: config.apiKey,
      },
      body: JSON.stringify({ ...search, analyticsTags: [surface] }),
      signal: AbortSignal.timeout(timeoutMs),
    });
    if (!res.ok) throw new Error(`status ${res.status}`);
    // One line here reads the answer and checks it against a schema (Answer).
    return { hits: answer.hits, nbHits: answer.nbHits };
  } catch (error) {
    console.warn(
      JSON.stringify({
        event: &apos;search.algolia_fallback&apos;,
        surface,
        reason: error instanceof Error ? error.message.slice(0, 120) : &apos;unknown&apos;,
      }),
    );
    return null;
  }
}
```

Three decisions are visible in it. It never throws. Missing configuration, a status other than 200, a timeout and a body that does not parse all return null, and null means &quot;answer from our own search&quot;. The time limit is a signal on the request itself, so a slow answer is abandoned rather than waited for. And the answer is checked against a schema before anything reads it, so a malformed body is one more failure rather than a crash further down. The box name travels as an Algolia analytics tag, so Algolia&apos;s own dashboard can tell blog searches from help searches without us sending anything else.

### Keeping the website index fresh

![Diagram of the hourly refresh. A scheduled sync reads the search records from the deployed site. An error from the site stops it there. If the records changed, it writes records and settings into a copy of the index that nobody searches, then moves the copy over the live index in one operation. A run that does not report in on time raises an alert.](/images/blog/how-teei-uses-algolia/index-refresh-path.svg &quot;How the website index stays fresh.&quot;)

Source: TEEI, drawn from the sync job that writes the website index, 13 October 2026.

- **The records come from the deployed site.** Every hour a scheduled job reads the search records from the live website, built by the same code its own search uses. The index can never hold a post or an answer the site would not show.
- **A broken source stops the job before Algolia is touched.** If the website answers with an error, nothing is written, so a bad deploy cannot empty the index.
- **It writes only when something changed**, judged by a fingerprint of the records and the settings. It also writes once a day anyway, so a change made by hand in Algolia&apos;s dashboard does not last long.
- **The copy replaces the live index in one operation**, so nobody ever searches a half-written index. The occupation index is written the same way.
- **A run that does not report in on time raises an alert.**

Most hours, nothing has changed. A typical line from the production job&apos;s log:

```text
unchanged index=teei_site_prod blog=59 faq=785 sha=ac72c3a79d78
```

### A platform limit

The TEEI Language help centre is not on Algolia yet. Its Worker already carries 128 settings and secrets, the most Cloudflare allows on one Worker on our plan.[^7] The three settings Algolia needs wait for a release that frees space.

[^1]: Algolia, [Algolia for Open Source](https://www.algolia.com/for-open-source), read 13 October 2026.
[^2]: ESCO, European Skills, Competences, Qualifications and Occupations, [esco.ec.europa.eu](https://esco.ec.europa.eu/en), read 13 October 2026. Our index uses ESCO version 1.2.1.
[^3]: Algolia documentation, [decompoundedAttributes](https://www.algolia.com/doc/api-reference/api-parameters/decompoundedAttributes/), read 13 October 2026.
[^4]: Algolia documentation, [Custom ranking](https://www.algolia.com/doc/guides/managing-results/must-do/custom-ranking/), read 13 October 2026.
[^5]: Algolia documentation, [Get logs](https://www.algolia.com/doc/rest-api/search/get-logs), read 13 October 2026. Each log entry includes the request body.
[^6]: Algolia documentation, [API key restrictions](https://www.algolia.com/doc/guides/security/api-keys/in-depth/api-key-restrictions/), read 13 October 2026.
[^7]: Cloudflare documentation, [Workers limits: environment variables](https://developers.cloudflare.com/workers/platform/limits/#environment-variables), read 13 October 2026.</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><category>teei-updates</category><category>Algolia</category><category>Search</category><category>TEEI Mentorship</category><category>Privacy</category><category>Engineering</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item><item><title>Ukrainian Learners Pass 75,000 Hours on DataCamp</title><link>https://theeducationalequalityinstitute.org/blog/ukrainian-learners-pass-75000-hours-on-datacamp-en/</link><guid isPermaLink="true">https://theeducationalequalityinstitute.org/blog/ukrainian-learners-pass-75000-hours-on-datacamp-en/</guid><description>Learners in TEEI Skills Academy have passed 75,000 hours on DataCamp. The dashboard read 76,032 on 21 September 2026. Every seat is free.</description><pubDate>Mon, 05 Oct 2026 00:00:00 GMT</pubDate><content:encoded>Ukrainian learners in TEEI Skills Academy have now spent more than 75,000 hours learning on DataCamp. The count runs from March 2022, through the DataCamp Donates partnership.

## Key facts

- 76,032 hours on the DataCamp platform, read from the dashboard on 21 September 2026.
- On 13 July 2026 the same dashboard read 70,865. The 75,000 mark fell between the two readings.
- 260,158 learning units completed, read on 21 September 2026.

## The readings

We do not estimate this figure. We read the hours on the platform from the DataCamp Donates group dashboard and publish them with the date of the reading.

| Read on | Hours |
|---|---|
| 13 July 2026 | 70,865 |
| 15 September 2026 | 75,536 |
| 21 September 2026 | 76,032 |

In the 30 days before the last reading, learners added roughly 2,340 hours.

## What the hours hold

The hours are time on the platform. The work done in them is counted separately, in learning units: one completed lesson, exercise, assessment or project is one unit. On 21 September 2026 the dashboard read 260,158 completed units.

| Kind of unit | Completed |
|---|---|
| Practice | 197,396 |
| Learn | 58,188 |
| Apply | 2,789 |
| Assess | 1,785 |

Three units in four are practice. In the 30 days before the reading, learners completed 8,258 units.

## What the figure does not say

Time on a platform is not a measure of what someone has learned, and it does not tell you who found a job. It shows that people keep coming back. None of this happens in bulk. One person opens a laptop after work, finishes a chapter, closes it, and comes back the next evening.

## The seats

Every seat is a DataCamp Donates licence, and it is free to the learner. Ukrainians can apply wherever they live, on the [DataCamp page of TEEI Skills Academy](/students/ukrainian/datacamp/).

Source: the DataCamp Donates group dashboard, read on 13 July, 15 September and 21 September 2026.</content:encoded><language>en</language><dc:creator>TEEI Team</dc:creator><authorRole>Content</authorRole><readingTime>2 min</readingTime><category>teei-updates</category><category>DataCamp</category><category>Skills Academy</category><category>Ukraine</category><category>Data skills</category><author>blog@theeducationalequalityinstitute.org (TEEI Team)</author></item></channel></rss>