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.
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.
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.
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.
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
- 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 - Run
wrangler d1 infoonce and look atrunning_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.