Skip to main content
TEEI in Practice
Engineering note · Research & Engineering · 15 October 2026 How TEEI Runs Its Platforms on Cloudflare Workers

TEEI in Practice

Engineering noteResearch & Engineering

How TEEI Runs Its Platforms on Cloudflare Workers

TEEI's website, TEEI Language, TEEI Mentorship and Evenhall run on Cloudflare Workers. This is how, with what broke and what we measured.

On this page
  1. What people do on these Workers
  2. At a glance
  3. One account, five production Workers
  4. Two days in June: moving everything to a new Cloudflare account
  5. From Pages to Workers
  6. One codebase, two Workers, and calls between platforms
  7. Live presence on a Durable Object
  8. Running the Worker next to the database
  9. Queues behind Evenhall
  10. The limits we hit
  11. Deploy and rollback
  12. Project Galileo in practice
  13. What this means for TEEI’s work
  14. A counter readers can check
  15. Thank you, Cloudflare
  16. Verify this
  17. Appendix: engineering notes
  18. Frequently asked questions

A learner picks a volunteer who is free right now, and the volunteer’s screen rings. A company manager invites employees into TEEI’s programmes, and each invitation is saved before an email goes out. Both run on Cloudflare Workers, like the rest of TEEI’s platforms: the website, TEEI Language, Skills Academy, TEEI Mentorship, Evenhall and the admin our own staff work in, all on one Cloudflare account. In Cloudflare’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 an error.

The Educational Equality Institute (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’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’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 own 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.

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, talk to a volunteer in a practice room, and later meet a mentor on TEEI Mentorship. 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'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.
Figure 1. 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. 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, 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’s public domain.

“I saw people all over the world who are so open-hearted… devoting their time.”

Oksana T. TEEI Language graduate

Figure 2. Oksana T., who learned English on TEEI Language, on what the sessions gave her. 1 min 21 s, with captions. Source: TEEI film library, with Oksana T.’s agreement to its use on TEEI’s pages; this cut was rendered on 22 September 2026.

Between language practice and mentoring sits Skills Academy, where volunteers teach live workshops, clinics, expert Q&As, webinars and classes. People earn certificates and badges as they go, which the admin Worker makes. And when a volunteer gives their time through an employer, the company’s managers see it in Evenhall.

The screens come from the public tours of TEEI Language and TEEI Mentorship, from Evenhall’s demo workspace 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 1. TEEI on Cloudflare, measured on 14 October 2026.Requests and errors are estimates from Cloudflare’s sampled analytics.
MeasureFigureNote
Traffic, 30 days
Requests to production Workers5.44 million14 September to 14 October
Requests that ended in an error0.002 %About 120 in 30 days, the last of them on 29 September
Storage and state
R2 buckets31
Stored in R218 GB381,023 objects
Durable Object namespaces19
KV namespaces17
D1 databases4
Account
Workers43Five of them serve production traffic
Domains4Moved onto the account on 28 and 29 June
Queues behind Evenhall12Dead-letter queues and staging included
Delivery
Production deploys of TEEI Language235Since 27 June, with one automatic rollback on 22 July

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 2. Requests to TEEI’s production Workers, 14 September to 14 October 2026.The figures come from Cloudflare’s sampled analytics, so each one is an estimate.
PlatformRequestsShare of totalErrors
Website3.11 million57.2 %0
TEEI Language1.39 million25.5 %50
Evenhall304,0005.6 %20
TEEI Mentorship and Skills Academy219,0004.0 %0
Admin112,0002.1 %0
Everything else in production302,0005.6 %50
Total5.44 million120

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 errors all came from Evenhall’s scheduler Worker between 14 and 17 September. Because Cloudflare’s analytics sample requests, 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.

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 production Worker, 14 September to 13 October 2026, each on its own scale. In Cloudflare'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 returned errors.
Figure 3. Requests per day to each production Worker. Each panel has its own scale, with the busiest day printed at its point, and a dot marks a day on which the Worker returned errors. Source: TEEI, from Cloudflare’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’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 production Workers

The parts are easier to see together.

Diagram of one Cloudflare account. Five production Workers stand in a row, each with the Cloudflare services it uses: the website with Static Assets, KV, R2 and two cron triggers; TEEI Language with a Durable Object, Logpush to R2, targeted placement 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 targeted placement; Evenhall with three queues, three dead-letter queues, KV and a scheduler Worker; and admin with Browser Rendering, 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, Resend and Amazon SES for email, Sentry for errors and Algolia for search.
Figure 4. One account: five production Workers, the Cloudflare services each one binds, and what stays outside. Source: TEEI, drawn from our Worker configurations and our Cloudflare account, 14 October 2026.

The website is an Astro 7 site that runs as a Worker with Static Assets. The Worker runs first for every request. Without that, the asset layer answers before the server code and returns, in the words of our configuration, “baffling 404s for routes that exist” only on the server. The website binds KV for rate-limit counters and R2 for videos, course certificates and the share cards for published sessions. The share cards have a bucket of their own. They are written by a renderer that runs outside Cloudflare, and an R2 token is scoped to a bucket, never to a prefix, so a shared bucket would let that renderer overwrite the certificates people put on LinkedIn. Two cron triggers run the website’s scheduled jobs.

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. Skills Academy, the step between them on TEEI’s path from language to skills to mentorship, is served from a second hostname on the Mentorship Worker. Each Worker has its own Durable Object namespace for live presence and runs next to the database through targeted placement. 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&As, webinars and classes sit above the search filters. The featured session is a sample expert Q&A with a button to register, and the week's sample sessions follow below it.
Figure 5. Skills Academy's Discover page, served from a second hostname on the Mentorship Worker. The sessions and hosts are samples. Source: TEEI, Skills Academy’s Discover page with sample sessions, captured for TEEI’s programmes page, October 2026.

Evenhall is where a partner company’s managers invite employees into TEEI’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 and a small scheduler Worker of its own.

The Impact overview in Evenhall'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 a screen from TEEI Language.
Figure 6. A company manager's view in Evenhall's demo workspace. The company and its figures are sample data. Source: TEEI, captured from Evenhall’s public demo workspace with sample data, 14 October 2026.

Admin is where TEEI’s staff run the programmes. It uses Browser Rendering to make the PNG badges and PDF certificates that people earn, and binds the same R2 bucket the website reads them from. It sends its trace events to a Tail Worker and uses Smart 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.
Figure 7. A sample TEEI Language certificate. The name and the certificate details are placeholders. Source: TEEI, the sample certificate shown on the website’s pages for TEEI Language, October 2026.

Those certificates and badges are most of what R2 holds. The films are next. Ihor R.’s, below, is one of them: 6.7 MB in the same square cut the stories wall plays, and Oksana T.’s above is 9.0 MB. Both reach the browser through the bucket’s public domain, in the byte ranges the player asks for, so nothing downloads until someone presses play.

“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.”

Ihor R. Career fellow on TEEI Mentorship

Figure 8. Ihor R., a career fellow on TEEI Mentorship, on what his mentors helped him with. 1 min 22 s, with captions. Source: TEEI film library, with Ihor R.’s agreement to its use on TEEI’s pages; this cut was rendered on 22 September 2026. File sizes measured on 15 October 2026.
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.
Figure 9. What fills R2. Certificates and badges hold most of the bytes; the platform logs hold most of the objects. Source: TEEI, from Cloudflare’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 portal, a health monitor, and Evenhall’s scheduler.

Some things are deliberately not on Cloudflare. The database is Turso, 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 the change that paid off. Video calls run on Amazon Chime SDK, 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. Email goes out through Resend and Amazon SES, errors go to Sentry, and search for help answers and job titles runs on Algolia.

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

On 28 and 29 June 2026 we moved TEEI’s Workers, domains and buckets to a new Cloudflare account, using the Cloudflare API, Wrangler and rclone.

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.
Figure 10. 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. 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 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’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. 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’s build provides.
  4. 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 off until cutover. Both accounts talked to the same database. If a Worker’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’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’s Worker was 4.94 MiB compressed, more than the Free plan allows, so we turned on Workers Paid 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’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, Sentry and a health monitor Worker.

From Pages to Workers

Two of our applications had left Cloudflare 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’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, “so counters keep their state across the cutover”. 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’s cache does for the zone is easiest to see by day.

Stacked area chart of requests per day to TEEI's main zone, 14 September to 13 October 2026, by cache status. In Cloudflare'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.
Figure 11. Requests to TEEI's main zone by cache status. The zone carries the website and the platforms' hostnames. A hit was answered from Cloudflare'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. Source: TEEI, from Cloudflare’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’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’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.
"workers_dev": 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, and needs its own approval. The account has seven Workers 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. 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’s scheduled jobs reach Mentorship the same way. Evenhall exports two named entrypoints, one for activating a company’s application and one for the admin’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 production Workers over the 30 days to 14 October 2026, in Cloudflare'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's request count.
Figure 12. Subrequests for each request answered, 30 days to 14 October 2026. Admin makes the most calls for each request; the website the fewest. Source: TEEI, from Cloudflare’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 “temporarily unavailable” on its first staging deploy, while the same URL answered 200 from anywhere else.

A Worker’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. Without the flag, the request goes to the zone’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’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. The Language Worker therefore adds two headers to the room pages, and only to those:

ts
if (isVideoSurfacePath(pathname)) {
  cloned.headers.set('Cross-Origin-Opener-Policy', 'same-origin');
  cloned.headers.set('Cross-Origin-Embedder-Policy', 'credentialless');
}

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’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’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. 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 ("Available now").
//
// ONE instance per environment (idFromName('live')). 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 "live set changed" ticks to learners and "ring" takeovers to
//     volunteers,
// (c) tracks transient ring state (claimed -> 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. Callers therefore
// NEVER await this DO on their critical path.
Sequence diagram with five columns: the learner's browser, the Language Worker, the Turso database, the LivePresence Durable Object and the volunteer's browser. The volunteer'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 without waiting for it. The Durable Object rings the volunteer'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 cancels the booking in the database and tells the learner, even if every browser tab has closed.
Figure 13. A ring, from the learner's choice to the volunteer's screen. The database is written first, and nothing waits for the Durable Object. 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, 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 only says that something changed, and the page loads the truth from the database. If the object is cold or broken, learners still get push notifications, email and a page that refreshes itself. The booking itself never depends on it.

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’s age from the newest boundary, and the newest boundary moved forward five minutes on every check, so a watch never grew old enough to retire and its list grew by one number every five minutes for the lifetime of the object. On 6 August the stored value passed its size limit, and every write to it failed with SQLITE_TOOBIG, including the alarm that cancels an unanswered ring. Removing old entries needed a write too, so nothing could shrink it again. The fix measures age from a fixed anchor stamped once, and caps the list when it is read rather than when it is written. A deploy alone therefore unlocks an object that is already stuck, because the next write is small enough to land.

Lost rings. Durable Objects run with an input gate that holds back new events while the object waits for its own storage, and lets them in while it waits for anything else. Our alarm read the map of open rings, waited for the database and the email provider, and wrote the whole map back. A ring that arrived during that wait was overwritten and never timed out. The fix gives each ring a generation and writes back only what is still the generation it read. We did not reach for blockConcurrencyWhile, because, as the commit puts it, “on the single global DO that would block every socket message for the duration of a Resend call.” The test harness had the same blind spot as the code: its fake storage handed back the live object instead of a copy, so the old test passed with the bug in place. It now returns a structuredClone, as real storage does.

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’s 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. Cloudflare logs the URL of each request to a Durable Object, and our URLs carried a personal identifier. Since 11 October it travels in a request header instead, so the logs no longer contain it. 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. Preview URLs are not generated for a Worker that implements a Durable Object, so every agent and developer who needs a live copy of the platform gets a Worker of their own. They accumulate, so on 22 September we deleted the preview Workers that nobody was using any more.

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’s own time on each page request and grouped it by the data centre where the request came in. The median was 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; only the distance to the database differed. A typical page made 13 to 15 trips to the database, nearly one after another, and each trip took about 123 milliseconds from the data centre that served the person. The dashboard learners open first made 72.

The cause was simple once we saw it. 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 cost them.

We had tried Smart Placement on staging at the end of August. Smart Placement decides where to run a Worker from the traffic it sees, and our staging Worker carried about 175 requests in a week, which is not enough for it to decide anything. So on 21 September we set the placement ourselves:

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-<location>) and the
// per-request dbTotalDurationMs/dbRoundTrips in Workers Logs; rollback is
// removing this key.
"placement": {
  "mode": "targeted",
  "region": "aws:eu-west-1"
},

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. Proving the move takes one header: a request to Language from Oslo today answers with cf-placement: remote-LHR, which means the Worker ran in London, and a cf-ray that ends in OSL, where the request came in. Rolling it back means deleting four lines.

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 21 September 2026 with the seven days from 7 to 13 October 2026. The median page time fell from 310 milliseconds to 179, the average of the daily medians, and the 90th percentile from 1,348 milliseconds to 499, the average of the daily 90th percentiles, database round trips per page from 12.4 to 5.8, and the time per round trip from 121 to 59 milliseconds.
Figure 14. TEEI Language pages before and after the change. Placement shortened each database trip; other changes in the same release removed trips. Source: TEEI, from Workers Logs for successful page requests that read the database, timed inside the Worker, 14 October 2026.

The change went out on 22 September as part of a larger release, and the first 41 minutes looked remarkable: a median page of 85 milliseconds, against 310 the week before. That did not hold over whole days, and we do not quote it as the result. We measure the result over whole days, on the same kind of request: a successful page request that reads the database, timed inside the Worker. From 7 to 13 October, across about 126,000 such requests on Language, the median of each day lay between 164 and 236 milliseconds, 179 on average when each day is weighted by its requests, against 310 for the week before the change. The daily 90th percentiles averaged 499 milliseconds, against 1,348. Read over the week as a whole, the way the earlier figure was read, the median is 180 milliseconds and the 90th percentile 474. For Mentorship, the daily medians averaged 145 milliseconds.

Two things moved at once, and they should be kept apart. Placement made each trip to the database shorter: 59 milliseconds now, against 121. The same release also cut the number of trips, through a small read coalescer that shares identical reads within one request, rewritten loaders and new indexes: 5.8 trips per page now, against 12.4. Neither would have produced these numbers alone. 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’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'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.
Figure 15. 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 is 21 September, when the Workers moved next to the database. Source: TEEI, from Cloudflare’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: the time went to waiting on calls, not to computing. After 21 September 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 also looked at caching pages at the edge, and decided against it. A response with status 200 and no Cache-Control header can be cached heuristically for up to two hours, and most of our pages are someone’s own dashboard. One wrong default would show one person’s page to the next. A contract test now fails if a cache setting appears on the Language Worker, so the choice cannot be undone in passing.

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: applications, email and HR system sync. Each has a dead-letter queue.

Flow diagram of Evenhall's email queue. A manager presses a button in Evenhall. The Evenhall Worker writes an outbox row to the Turso database and sends only the row's ID to a Cloudflare Queue. The queue consumer claims the row, reads the recipient and content from the database, and sends the email through the email provider with an idempotency key. A send that fails in a way that can be retried waits for a delay that doubles from one minute up to an hour, up to eight retries; after that the message goes to the dead-letter queue, whose consumer records the row as failed for good, one batch at a time. The applications and HR system sync queues follow the same pattern. Separately, a sweep every five minutes alerts the team when a scheduled job goes stale.
Figure 16. Evenhall's queues carry only an ID. Everything else stays in the database, and every failure has somewhere to go. Source: TEEI, drawn from Evenhall’s queue configuration and consumers, 14 October 2026.

This is where one of those messages starts. A manager pastes the employees’ email addresses and presses Send invitations. For each address the Worker saves an invitation and an outbox row, a record of an email waiting to be sent, and sends only the row’s ID to the queue. If the queue does not take the message, the invitation is already saved, and a sweep picks up the waiting row later.

The Invite employees page in Evenhall'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.
Figure 17. Invite employees: for each address, an invitation and an outbox row in the database, and only an ID in the queue. Source: TEEI, captured from Evenhall’s public demo workspace with sample data, 14 October 2026.

The most important rule is in the configuration, above the first queue. Evenhall had an earlier name when this code was written, and the queue names still carry it:

toml
# Transactional email outbox. Payloads contain only the Turso outbox id; the
# consumer resolves recipient data after claiming the row.
# …

[[queues.consumers]]
queue = "teei-csr-email-production"
max_batch_size = 10
max_batch_timeout = 5
max_retries = 8
dead_letter_queue = "teei-csr-email-dlq-production"
max_concurrency = 2

[[queues.consumers]]
queue = "teei-csr-email-dlq-production"
max_batch_size = 10
max_batch_timeout = 5
max_retries = 3
max_concurrency = 1

A queue message is an ID, never an address, a name or a list of employees. The HR sync queue follows the same rule, and its comment says “roster data never enters Queues”. The consumer claims the row in the database first and reads what it needs only then. That keeps personal data out of the queue and keeps the database the one place that decides what has been sent. A row that already has a message ID from the email provider, or that has reached a final status, is acknowledged and skipped, so a message delivered twice sends one email. A send that fails in a way that can be retried waits for a delay that doubles from one minute and stops growing at an hour. After eight retries the message goes to its dead-letter queue, whose consumer marks the row as a dead letter and stops there; its own three retries only cover a failure while it does that.

Two problems taught us more than the happy path did.

The first was a database client created once and shared between requests. A libSQL call made in one request sometimes waited on a promise that had been created in another, and the runtime refused it: “promise resolved from a different request context”. The fix on 9 September was one client per request.

The second was silence. Evenhall’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 3. Platform limits and rules TEEI has run into in 2026, in the order we met them.The paragraphs below give the detail.
WhenLimitWhereWhat we changed
25 Jul20,000 static assetsWebsite12,115 redirect pages are no longer built, and a redirect rule on the zone answers those paths
7 AugWorker size, compressedTEEI LanguageOur size check now measures the compressed size: Language is 4.19 MB, under a ceiling we set at 4.25 MB
15 SepA promise shared between requestsTEEI LanguageEvery caller gets its own 1.5-second budget
21 SepFetch to another Worker on the same accountWebsiteThe global_fetch_strictly_public flag, or a service binding
27 Sep128 variables and secrets per WorkerTEEI LanguageA check that counts them before a deploy, flags moved into the database, and related secrets combined into one JSON secret
3 OctSix connections waiting for a responseTEEI Language, scheduled jobsJobs start four at a time, and two at a time on the heaviest trigger

128 variables. A Worker can carry 128 environment variables and secrets 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: “found a total of 129”. We added a check that counts them before a deploy, moved feature flags into the database, and combined related secrets into one JSON secret. Language stands at 128 of 128 today, which is why the most recent change to it made room before it added anything.

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 applies is the compressed size: 3 MB on the Free plan and 10 MB on Paid, with 64 MB before compression. The comment on the new check is honest about the other half: start-up time matters too, and “nobody has ever measured ours”. Language is 4.19 MB compressed today, and its ceiling is 4.25 MB, set on 13 October when Spanish and Portuguese were added.

Twenty thousand files. Astro’s fallback for other languages wrote one redirect page per language for every city page on the website, 12,115 small files in all, and they pushed the website past the 20,000-asset limit. We stopped building them. A rule we expected to answer those paths in our middleware never ran in a production build, because the framework’s language routing answered with a 404 before our middleware saw the request. A dynamic redirect rule 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 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. On 22 July a Language deploy failed its probe at 07:10:12 UTC, and the previous version was back six seconds later. The fix went out at 08:05 and passed. On 28 July a Mentorship deploy rolled back because the probe itself was wrong, while the service was fine. We count that as the rollback working, and we fixed the probe.

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 they were hit, from 20,000 static assets on 25 July to six connections waiting on 3 October.
Figure 18. Production deploys of TEEI Language per week since 27 June 2026, with the one rollback and the six limits from the section above marked on the week each was met. 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 the Workers moved next to the database. The week of 3 August had none.

Rollback is quick because Workers keep versions. We rehearsed it on staging and measured 4.3 seconds from the command to the older version serving, and 4.1 seconds to restore the newer one. 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, so what was approved is what goes live.

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, and the bucket holds 318,916 log files today. 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.

Project Galileo in practice

TEEI’s main domain runs on Cloudflare’s Business plan through Project Galileo. Most of what that buys is invisible, so here is what it did in three days.

From 11 to 14 October 2026, Cloudflare’s analytics count about 1.03 million requests to the main domain from people and bots. The Cloudflare Managed Ruleset blocked about 14,000 of them, or 1.4 percent. More than 10,500 were attempts to read 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 the probes were looking for from the rules they matched.

Horizontal bar chart of requests blocked by the Managed Ruleset across TEEI's main zone in the 72 hours to 14 October 2026, by rule, in Cloudflare'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.
Figure 19. What the Managed Ruleset blocked across the main zone in three days, by rule. The names are shortened from Cloudflare's own rule names. Source: TEEI, from Cloudflare’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 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’s work

TEEI’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 Worker spent 2.7 seconds on the median page for a request that came in through Oceania, against a quarter of a second in Western Europe. Work like the placement change is how we close that gap. 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 are built from data that passes through the same Workers.

A counter readers can check

The public statistics 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 pins each published figure, so that a citation stays true, and under the record it prints the counter as it stands now. The website’s Worker asks the Language Worker for that number, so the reader’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'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.
Figure 20. The website's impact page on a phone: the pinned record, and under it the live counter from the Language Worker. Source: TEEI, captured from the impact page on TEEI’s website at phone width, 14 October 2026.

Thank you, Cloudflare

Through Project Galileo, Cloudflare gives TEEI the Business plan on 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 are answered by the Language Worker as you open them.
  • If you are building with Cloudflare or with TEEI, talk to us and we will walk you through the code.

Appendix: engineering notes

For developers who want the detail behind the sections above. Each excerpt is copied from the code that runs in production today, with ticket numbers, identifiers and unrelated lines removed, and long comments rewrapped to fit the page.

The Durable Object binding

The live presence object is declared once and migrated as a SQLite-backed class. Named environments do not inherit it, so the staging block repeats the binding.

jsonc
"durable_objects": {
  "bindings": [{ "name": "LIVE_PRESENCE", "class_name": "LivePresence" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["LivePresence"] }],

Keep-alive without waking the object

The constructor tells the runtime to answer the browser’s ping itself. A hibernating object is never woken just to say that it is still there.

ts
class LivePresenceBase extends DurableObject<AppEnv> {
  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’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.
"services": [
  {
    "binding": "MENTORSHIP_SCHEDULED_WAVE_SERVICE",
    "service": "teei-mentorship-rr7"
  },
  {
    "binding": "CSR_APPLICATION_ACTIVATION",
    "service": "teei-csr-cockpit-rr7",
    "entrypoint": "CsrApplicationActivation"
  },
  // …
],

Logs that outlast the window

The comment is the lesson.

jsonc
"observability": {
  "enabled": 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.
// …
"logpush": 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'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’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, “a var or a secret travels with the Worker version”, 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.

Frequently asked questions

Which TEEI platforms run on Cloudflare Workers?

The website, TEEI Language, Skills Academy, TEEI Mentorship, Evenhall and the admin our staff use. They run as Workers on one Cloudflare account, and Skills Academy is served by the Mentorship Worker on a hostname of its own. Smaller Workers handle redirects, schedules and monitoring.

Where is the data stored?

The programme data lives in a Turso database in Europe (Ireland). Files such as course certificates, videos and logs are stored in Cloudflare R2. Video calls run on Amazon Chime SDK.

Why does the Worker run in London when I am somewhere else?

Because the database is in Ireland. A page needs several database reads, so running the Worker close to the database is faster than running it close to you. Static files are still served from the data centre nearest to you.

How does TEEI use Durable Objects?

For live presence: one Durable Object holds the open connections of people who are online, rings a volunteer when a learner picks them, and cancels the session if nobody answers. The database stays the source of truth.

What is Project Galileo?

Cloudflare's programme for organisations doing public-interest work. Through it, TEEI's main domain runs on Cloudflare's Business plan, including the managed firewall rules that block probes for exposed files.

Can I check the numbers in this article?

Some of them, yes. The Verify this section lists a request you can send to see where our Worker runs, and a public statistics address that the Language Worker answers live.

More in Research & Engineering

Partnerships

Talk to us about building with TEEI

We use these details only to reply.