/friends

Changelog · Thursday, September 3, 2026

🌍 Check where your Cloudflare D1 database lives

A speed bug I found by accident, what caused it, and the three fixes I shipped today.

If you use Cloudflare D1 and have a VPS, check where your database actually lives.

I found out that my Cloudflare databases were in Europe and I didn't know it. So two of my sites were doing trans-Atlantic round trips on every uncached page load, on accident. It slowed down the sites and made my crawl times worse.

BEFORE three queries, three ocean crossings Visitor Seattle Worker Seattle Atlantic D1 primary WEUR (Western Europe) fixed at creation query 1 · query 2 · query 3 about 150 ms each, one after another
Seattle edge, Worker in Seattle, query across the Atlantic to Western Europe, back to Seattle, another query across, back again. Then a third time.

How a database ends up in France

D1 puts the primary database in the region closest to wherever the wrangler d1 create command was run from. Not where your Worker runs, but where the terminal was at the time of creation.

If you use Hetzner or another VPS for your Claude Code sessions and that server is in Europe, there's a good chance your database gets put in Europe too.

For this site, my colleague overseas created the database, probably from our Hostinger VPS in Europe. Either way it landed in WEUR (Western Europe). For my huge PatronView site, we created the database from that same VPS and it also got put in Marseille, France.

One problem is that it can't move after you make it. The location of your D1 is fixed at creation. Cloudflare's docs say so.

Seattle to Europe and back, three times per page

Meanwhile the Cloudflare Worker runs wherever the visitor is, which is great. Most of my visitors and all of Googlebot's crawling come from the United States. So a USA visitor's request went: Seattle edge, Worker in Seattle, query across the Atlantic to Western Europe, back to Seattle, another query across, back again.

That was taking about 150 ms per query. A page with three sequential queries paid 300 to 450 ms just in database hops.

How I noticed

I only noticed this because the Google Search Console crawl stats showed average response time climbing from about 200 ms to 450 ms on this site over the last week. My PatronView donor database showed the same shape, about 200 to 390 ms.

Search Console · average response time slashfriends.org, late August 2026 0 250 500 ms ~200 ms ~450 ms Googlebot finds the new /site/ pages
The shape of the problem. Cached pages were fine the whole time. The climb is Googlebot hitting hundreds of brand-new URLs, every one of them a cache miss.

In both cases the trigger was Googlebot discovering a lot of new dynamic URLs at once, so most of its requests were cache misses that ran the full query path. Thankfully cached pages were fine the whole time, because here the edge cache answers before the Worker or the database ever get involved.

What I did today to fix it

1. Batched the queries

Each page now sends all its reads to D1 in one env.DB.batch() call instead of one at a time. Three ocean crossings became one.

// before: three round trips, each one waits for the last
const site = await env.DB.prepare(siteSql).bind(domain).first();
const ring = await env.DB.prepare(ringSql).bind(domain).all();
const friends = await env.DB.prepare(friendsSql).bind(domain).all();

// after: one round trip const [site, ring, friends] = await env.DB.batch([ env.DB.prepare(siteSql).bind(domain), env.DB.prepare(ringSql).bind(domain), env.DB.prepare(friendsSql).bind(domain), ]);

2. Turned on Smart Placement

I added one line to wrangler.toml to turn this on. Smart Placement moves the Worker to run next to the database, so an uncached request pays one long hop (visitor to Worker) instead of one per query. Uncached loads dropped from roughly 430 to 740 ms, down to 260 to 540 ms.

[placement]
mode = "smart"

But Smart Placement is not the real fix. It makes the Worker far from the visitor instead, so any cached response that the Worker serves itself now carries the ocean crossing. Womp womp. On PatronView we measured a 230 ms floor on cached responses served from Kansas City because of it.

SMART PLACEMENT one crossing per request, but cached pages cross too Visitor Kansas City Atlantic Worker France D1 primary France one long hop fast 230 ms floor on cached responses from Kansas City. Womp womp.
Better for cache misses, worse for everything the Worker serves from its own cache. The ocean did not go away. It just moved.

3. The real fix: D1 read replication

So I did the real fix the same afternoon: D1 read replication. It's free, you turn it on in the dashboard or via the API, and D1 puts a read-only copy in all six regions, including both US regions.

# turn it on with one API call
curl -X PUT "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/d1/database/$DB_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"read_replication": {"mode": "auto"}}'

The catch is that your code has to use the Sessions API (env.DB.withSession()) or every query still goes to the primary. Every read on this site now goes through one small helper:

// src/lib/db.ts
export function readDb(env: Env) {
  // first-unconstrained: the first query may go to the nearest
  // replica with no bookmark requirement, which is the whole
  // point for a page render.
  return env.DB.withSession('first-unconstrained');
}

// pages that only read use readDb(env). Writes keep using env.DB.

Once that was in, I turned Smart Placement off. The Worker runs at the visitor's edge again and reads from a nearby replica. This site is running that way now. PatronView is next.

READ REPLICATION reads stay in the US, writes still go to Europe Visitor Seattle Worker Seattle D1 replica WNAM (US West) Atlantic D1 primary Western Europe reads: nearby syncs, lags a moment writes still cross the ocean (rare, that is fine)
The Worker is back at the visitor's edge. Reads come from a replica a few hundred miles away. Only writes make the long trip, and a directory like this one gets read far more often than it gets written to.

One gotcha with replicas: they lag the primary by a moment. Right after a write, a page rendered from a replica can miss the new row, and if that page gets cached it stays stale for the whole TTL.

I purge the cache a second time, a few seconds after each write, to close that window.

Two things I'd tell anyone starting a new D1 project

  1. Pass the location flag explicitly. Don't let it guess from your IP. You do not need a proxy or a US connection, just the flag.
    wrangler d1 create mydb --location enam   # or wnam
  2. Run wrangler d1 info once and look at running_in_region. If it doesn't match your users, turn on read replication before you write a lot of query code, because retrofitting the Sessions API is the annoying part.
    wrangler d1 info mydb
    # look for:
    #   running_in_region: WEUR   <-- this one

For nerds: Every read-only route on the site (homepage, browse, profiles, this changelog, the feed, the sitemap, sites.json, the favicon proxy) now reads through the replica session. Writes from the submit form, the admin pages, and the nightly cron still go straight to the primary.