TL;DR: My SaaS dashboard took 2.7 to 4.6 seconds to switch tabs. The code was fine. The Worker ran in a Cloudflare data center near Dhaka, the Postgres database was in Virginia, and every page made several queries one after another. Each query crossed the planet twice. Adding Hyperdrive halved it, moving the database to Singapore took it to about 150 ms, and pinning the Worker next to the database smoothed out the rest. No code was optimized along the way.

The setup

I am building a multi-tenant e-commerce SaaS for Bangladeshi Facebook sellers: one SvelteKit app on Cloudflare Workers serves the marketing site, the merchant dashboard and every store's storefront. The database is Neon Postgres, accessed with Drizzle and postgres.js.

When I created the Neon project I left the region on the default, us-east-1 (North Virginia). Almost every user of this product is in Bangladesh.

Measuring first

I opened the dashboard, logged in, and ran a small script in the browser console that clicks each sidebar link and records how long the page's __data.json request takes. SvelteKit fetches that file on every client-side navigation, so its time to first byte (TTFB) is almost pure server time.

const before = performance.getEntriesByType('resource').length;
link.click();
// ...wait until the URL changed and a __data.json request finished...
const d = performance.getEntriesByType('resource')
  .slice(before)
  .find((r) => r.name.includes('__data.json'));
const ttfb = d.responseStart - d.requestStart;

I ran the same loop (Products, Orders, Customers, Categories, Reports, Orders, Products, Overview) after every change, so the numbers below are comparable.

Why it was slow

A Worker runs in the Cloudflare location closest to the visitor. For me that is somewhere in South or Southeast Asia. The database was in Virginia, roughly 250 ms or more away each way.

A dashboard page does not make one query. It checks the session, loads the store membership, then loads the page's own data. Some of those depend on each other, so they run in sequence. On top of that, the very first query of a request had to open a new TCP + TLS + Postgres auth handshake, which is several more round trips.

So a page cost something like:

(handshake round trips + sequential queries) x ~0.5 s per round trip

That is how you get 3 to 4 seconds out of queries that each take a millisecond inside the database.

Page us-east-1, direct
Products 2757 ms
Orders 4339 ms
Customers 2754 ms
Reports 4343 ms
Overview 4582 ms
Average ~3.6 s

Step 1: Hyperdrive

Workers cannot keep a database connection open between requests, so each request paid the full connection handshake. Hyperdrive fixes exactly that: it keeps a warm pool of connections near the database and gives the Worker a connection string that points at it.

The code change is small. In production the client uses the binding; in dev it keeps using the normal URL:

const hyperdrive = dev ? undefined : env.HYPERDRIVE;
const sql = postgres(hyperdrive?.connectionString ?? DATABASE_URL, {
  max: 5,
  fetch_types: false,
  // Hyperdrive supports prepared statements, the Neon pooler does not
  prepare: Boolean(hyperdrive)
});

One gotcha: if the binding is in wrangler.jsonc, local dev refuses to start unless the binding has a local connection string. Since dev ignores the binding anyway, a placeholder is enough:

"hyperdrive": [
  {
    "binding": "HYPERDRIVE",
    "id": "<hyperdrive-id>",
    "localConnectionString": "postgres://unused@localhost:5432/unused"
  }
]

Result: about 1.9 s on average. Better, but every query still crossed the ocean. Hyperdrive removes the handshake, not the distance.

Step 2: move the database to Singapore

Neon cannot change a project's region in place. You create a new project in the target region and copy the data. For a small database that is a dump and restore.

My local pg_dump was version 16 and the server was Postgres 18, which pg_dump refuses. Docker solves that without installing anything:

docker run --rm -e SRC="$OLD_URL" -v "$PWD/dump:/dump" postgres:18 \
  sh -c 'pg_dump -Fc --no-owner --no-acl -d "$SRC" -f /dump/neon.bak'

docker run --rm -e DST="$NEW_URL" -v "$PWD/dump:/dump" postgres:18 \
  sh -c 'pg_restore --no-owner --no-acl -d "$DST" /dump/neon.bak'

Use the direct (unpooled) connection strings for both; pg_dump over PgBouncer is asking for trouble. --no-owner avoids ALTER OWNER statements that the Neon role cannot run.

Then I compared the exact row count of every table in both databases before switching anything. All 29 matched.

The cutover was two commands: point Hyperdrive's origin at the new database, and update the DATABASE_URL secret.

wrangler hyperdrive update <id> --connection-string="$NEW_DIRECT_URL"
wrangler secret put DATABASE_URL < new_pooled.url

Do not forget Hyperdrive. It stores its own copy of the origin connection string, so updating only DATABASE_URL would have left production quietly talking to the old database.

Result: 124 to 199 ms. Same code, same queries.

Step 3: put the Worker next to the database

With the database in Singapore, there was still a choice about where the Worker runs. By default it runs near the visitor, and each query goes from there to Singapore. Workers have a placement setting that pins the Worker next to a cloud region instead:

"placement": { "region": "aws:ap-southeast-1" }

Now a visitor's request makes one trip to Singapore, and all the queries inside it are local. Static assets are still served from the edge nearest the visitor, so only the dynamic part moves.

Result: 118 to 160 ms per page, and the full page load went from 268 ms to 162 ms. A small gain at this point, but it removed the slow outliers.

All the numbers

Server TTFB per dashboard page, measured from Bangladesh:

Setup Per page Full page load
us-east-1, direct connection 2.7 to 4.6 s
us-east-1 + Hyperdrive 1.1 to 2.8 s 2.5 s
Singapore + Hyperdrive 124 to 199 ms 268 ms
Singapore + Hyperdrive + placement 118 to 160 ms 162 ms

What I took away

  • Measure the network part separately. TTFB of the data request told me immediately that the time was on the server, not in rendering.
  • Count round trips, not milliseconds per query. A query that takes 1 ms in Postgres costs half a second when the database is on another continent. Sequential queries multiply that.
  • Pick the database region for your users on day one. Moving it later is a dump, a restore, and a careful cutover. Choosing the right region at the start is one dropdown.
  • Hyperdrive and placement are multipliers, not fixes. They help a lot once the database is close. They cannot beat the speed of light to Virginia.

I had already written about this problem years ago, when my WordPress API sat on a Bangladeshi host and my frontend ran in North Virginia: Website Speed. Same lesson, different stack: put the code, the data and the users in the same part of the world.