Skip to main content
TEEI in Practice
Engineering note · Research & Engineering · 15 October 2026 Cloudflare Workers: Shorter Trips, Fewer Trips

TEEI in Practice

Engineering noteResearch & Engineering

Part of: Cloudflare Workers

Cloudflare Workers: Shorter Trips, Fewer Trips

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.

On this page
  1. The symptom, measured
  2. Two quantities: how many trips, and how long each one is
  3. Fewer trips: one HTTP request for the reads of one turn
  4. Why batching stopped at 26
  5. Shorter trips: running the fetch handler next to the database
  6. What the release changed, and what we can credit to what
  7. What placement costs
  8. When this is the wrong choice
  9. Verify this
  10. Frequently asked questions

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, and both use that one database. How TEEI runs its platforms on Cloudflare Workers 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’ 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’s own time, not the person’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’s own processing and any queueing as well as the distance, so it is an upper bound on network time. And the dashboard’s 72 trips did not run one after another: many were issued together through Promise.all. What set the page’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.
Figure 1. One request, before and after placement. Before, we crossed the long distance once per dependent stage; after, we cross it once per request. 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 'read' 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, 'read') 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 = () => {
    if (store.queue.length > 0) flush(store);
  };

  const wrapped: Client = {
    execute(stmtOrSql: InStatement | string, args?: InArgs): Promise<ResultSet> {
      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’s fetch handler and construct the loaders’ 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’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: () => 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’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.
Figure 2. 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. 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<T>(read: () => Promise<T>): Promise<T> {
  let promise: Promise<T>;
  try {
    promise = read();
  } catch (err) {
    promise = Promise.reject(err);
  }
  promise.catch(() => 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’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 “this change”.)

ts
describe('/home loader statement count does not grow with the booking list', () => {
  it('issues the same number of statements for 1, 5 and 20 accepted bookings', async () => {
    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’s own documentation says so: “Turso runs the batch sequentially on one connection; the win is the network exchange, not the SQL.” 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’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’s repeated work.

Shorter trips: running the fetch handler next to the database

Placement has two forms. With Smart Placement, Cloudflare measures a Worker’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’s region, so on 21 September we named it as our placement target rather than waiting for a traffic-based decision:

Verbatim excerpt.

jsonc
  "placement": {
    "mode": "targeted",
    "region": "aws:eu-west-1"
  },

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, 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’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.
Figure 3. Where the code ran, before and after the placement region was declared; sampled, so each value is an estimate. 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’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 1. Language Worker GET page requests that returned 200 and made at least one database fetch, timed inside the Worker, from Workers Logs.September: our release record, percentiles over the whole window. October: measured on 15 October 2026 with the duration our application logs when a request completes, sampled (intervals 1.02 to 1.10), so the request count is an estimate; median and 90th percentile are request-weighted averages of daily percentiles.
WindowRequestsMedian90th percentileTrips per pagems per trip
7 days before the release138,850310 ms1,348 ms12.4121
8 to 14 October 2026135,341176 ms449 ms5.850.7 (daily 41.3 to 61.8)

Read as one window, the way the September week was read, from 7 October 13:39 UTC to 14 October 13:38 UTC, the median was 180 milliseconds and the 90th percentile 474; that query had a sample interval of 10.48.

Trips per page, 12.4 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, 121 to 50.7 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 310 to 180 milliseconds and a 90th percentile of 1,348 to 474: 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 cannot confirm which duration field we used for the September row; if it was invocation wall time, the comparison may overstate the fall.

We also cannot show that the regional gap from September closed. We measured October differently, and we have no measurement of a person’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’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’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’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’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.

Frequently asked questions

What is targeted placement on Cloudflare Workers?

A placement setting that names a cloud region, such as aws:eu-west-1. Cloudflare then runs the Worker’s fetch handler in the data centre with the lowest latency to that region, instead of the one nearest the user. When the asset layer answers before the Worker, static assets are still served nearest the user.

Should I use Smart Placement or a placement region?

For a backend in one region and several dependent database calls per request, a placement region gives you an explicit target; measure the forwarding cost against the database trips it replaces. With Smart Placement, Cloudflare chooses the location from traffic measurements and needs consistent requests from several locations. Our staging trial, started on 31 August 2026, never reached a decision.

Does placement reduce the number of database round trips?

No. We use placement to shorten each trip between the Worker and the database. The number of trips falls only when you change the code: we batched concurrent reads into one HTTP request and rewrote loaders that read once per row. In our release both changes went out together.

How do I check where my Worker ran?

Look at the cf-placement response header. remote-LHR means the request was forwarded to a data centre near London; the last three letters of cf-ray name the data centre that received it. Cloudflare notes the header may be removed, so keep your own per-request timing as well.

Does placement affect cron triggers or Durable Objects?

No. Placement only affects fetch handlers. scheduled() runs as before, Cloudflare places Durable Objects by a separate mechanism, and RPC methods and named entrypoints are not affected.

More in Research & Engineering