
Managed Postgres With Your Own VPS: How to Live With the Latency
Your app runs on a VPS and your Postgres runs on a managed service from another provider. Every query now crosses the internet. Where the extra latency comes from, how to measure it, and the fixes that actually make it small.
Managed Postgres With Your Own VPS: How to Live With the Latency
A common setup in 2026: the app runs on a cheap, fast VPS, and the database is a managed Postgres from a different company, because you don’t want to own backups, failover and upgrades. It’s a good trade. It has one cost you can’t negotiate away: the database is no longer next to the app.
On a single server, a query is a local socket call that takes microseconds. Across providers, every query is a trip over the public internet, typically 1–20 ms there and back. That number is fixed by geography and routing. What you control is how many times you pay it, and that is where almost all of the optimization lives.
This guide covers where the latency comes from, how to measure it from your server, and the fixes in order of how much they usually buy you. Nothing here is specific to one VPS host or one database vendor.
Where the time goes

Where the time goes
A query from your VPS to a managed database pays for:
- Distance. Light in fiber covers about 200 km per millisecond, so every 100 km of cable adds roughly 1 ms of round trip. Cables don’t run in straight lines, so real paths are longer than the map suggests.
- Routing. Two providers in the same city can still be several milliseconds apart if their networks don’t peer there and traffic detours through another city. This is the part you can’t see from a pricing page.
- The provider’s front door. Managed services usually put a proxy or connection pooler in front of Postgres. That adds a hop, often small, sometimes not.
- Connection setup, if you open a new connection: several extra round trips before the first query (see below).
- Query execution, which is the same as it would be anywhere.
Rough round-trip times to expect, before any query work:
| Where the app and database are | Typical round trip |
|---|---|
| Same server or same datacenter | 0.1–1 ms |
| Same city, different providers | 0.5–3 ms |
| Neighbouring cities (Amsterdam ↔ Frankfurt, Nuremberg ↔ Frankfurt) | 3–10 ms |
| Same continent, far apart (London ↔ Warsaw, Frankfurt ↔ Helsinki) | 15–40 ms |
| US East ↔ US West | 60–75 ms |
| US East ↔ Western Europe | 75–95 ms |
These are ranges, not promises. Measure your own path (next section).
Why a few milliseconds turns into a slow page
A page rarely makes one query. Queries that run one after another each wait a full round trip, so the network cost is round trip × number of sequential queries:
| Sequential queries per request | 1 ms RTT | 5 ms RTT | 20 ms RTT | 80 ms RTT |
|---|---|---|---|---|
| 1 | 1 ms | 5 ms | 20 ms | 80 ms |
| 10 | 10 ms | 50 ms | 200 ms | 800 ms |
| 50 | 50 ms | 250 ms | 1 s | 4 s |
That’s why an app that felt instant with a local database suddenly feels sluggish. At 0.1 ms per trip, an N+1 loop of 50 queries costs 5 ms and nobody notices. At 20 ms it costs a full second.
What a new connection costs
Opening a fresh TLS connection to Postgres takes several round trips before the first query runs:
| Step | Round trips |
|---|---|
| TCP handshake | 1 |
Postgres SSLRequest | 1 |
| TLS 1.3 handshake | 1 |
| Startup message and auth request | 1 |
| SCRAM-SHA-256 authentication exchange | 2 |
That’s around 6 round trips, plus server-side work: about 30 ms at 5 ms RTT, and over 100 ms at 20 ms RTT. An app that connects per request pays this on every request, on top of its queries. Postgres 17 clients can skip the SSLRequest step with sslnegotiation=direct, but only if your provider’s proxy supports it.
Step 1: Put them in the same city
Latency you avoid by choosing locations is free. Everything later in this guide only reduces how often you pay it.
- Pick the database region first, then put the VPS in the same metro. Managed Postgres regions are usually named after cloud regions (
eu-central-1is Frankfurt,us-east-1is Northern Virginia), so check which city that actually is. - Many managed Postgres services run on AWS, Google Cloud or Azure. VPS providers with a datacenter in the same metro (Frankfurt, Amsterdam, London, Ashburn, etc.) are often 1–2 ms away, because the big exchanges in those cities connect almost everyone.
- Region names are not cities. A provider’s “EU Central” can mean Frankfurt, Nuremberg or Helsinki depending on who’s naming it. Read the docs, then measure.
- Same city isn’t automatically fast. If the two networks don’t peer locally, traffic can go via another city and back. Test before you commit; most VPS providers bill hourly, so trying two locations costs a few cents.
If you already have a VPS and need a database: choose the managed region closest to it. If you already have a database: move the VPS, which is usually the cheaper thing to move.
Step 2: Measure from the VPS
Run these on the server, never on your laptop. Your home connection tells you nothing about the path between two datacenters.
Round trip on an open connection (apt install postgresql-client):
psql "$DATABASE_URL" <<'SQL'
\timing on
SELECT 1;
SELECT 1;
SELECT 1;
SELECT 1;
SELECT 1;
SQL
Ignore the first timing. The rest are roughly one network round trip each. If your provider offers both a pooled and a direct connection string, run it against both: the difference is what the pooler adds.
Cost of a new connection (pgbench ships with the Postgres server package on most distros):
echo 'SELECT 1;' > select1.sql
pgbench -n -f select1.sql -t 100 "$DATABASE_URL" # reuses one connection
pgbench -n -f select1.sql -t 20 -C "$DATABASE_URL" # new connection every time
Compare latency average from the first run with average connection time from the second. The second number is what a connect-per-request app pays on every request.
The route itself:
mtr -T -P 5432 -c 200 your-db-host.example.com
-T -P 5432 traces over TCP, because database hosts often ignore ping. Watch for hops that jump to another city. Run it at a few different times of day: routes change, and a path that’s 4 ms at noon can be 8 ms at night.
One query vs ten, from your app’s runtime (Node example, npm install pg):
// rtt.mjs: DATABASE_URL="postgres://..." node rtt.mjs
import pg from "pg";
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
await client.query("SELECT 1"); // warm-up
async function median(label, runs, fn) {
const times = [];
for (let i = 0; i < runs; i++) {
const t = performance.now();
await fn();
times.push(performance.now() - t);
}
times.sort((a, b) => a - b);
console.log(`${label.padEnd(22)} ${times[runs >> 1].toFixed(1)} ms`);
}
const ids = [...Array(10).keys()];
await median("1 query", 30, () => client.query("SELECT 1"));
await median("10 queries in a loop", 10, async () => {
for (const id of ids) await client.query("SELECT $1::int AS id", [id]);
});
await median("10 rows, 1 query", 10, () =>
client.query("SELECT id FROM unnest($1::int[]) AS id", [ids]));
await client.end();
The gap between the last two lines is exactly what N+1 queries cost you on this link. If your database bills per query, this runs about 100, so it won’t move the needle.
Step 3: Never connect per request
This is the single biggest fix for most apps, and it’s often a one-line setting. Check what your stack does by default:
| Stack | Default | Fix |
|---|---|---|
Node (pg) | Whatever you write. A new Client() per request reconnects every time. | One Pool per process, created at startup. |
| Django | Closes the connection after every request (CONN_MAX_AGE = 0). | CONN_MAX_AGE = 600 and CONN_HEALTH_CHECKS = True. |
| PHP (PHP-FPM) | Connects on every request and disconnects at the end. | Local PgBouncer (below), or PDO::ATTR_PERSISTENT. |
| Rails | Pooled per process. | Fine. Check pool matches your thread count. |
Go (database/sql) | Pooled, but keeps only 2 idle connections. | SetMaxIdleConns close to SetMaxOpenConns. |
| SQLAlchemy | Pooled (5 connections). | Add pool_pre_ping=True so dead connections are replaced. |
Then make sure the pool stays warm. Pools close idle connections to save resources. node-postgres’s default is 10 seconds, so on a quiet site almost every request finds an empty pool and pays the full handshake again. Raise it:
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 300_000, // 5 minutes
keepAlive: true,
});
Keep the idle timeout below your provider’s own idle limit. Managed proxies close idle connections after a while, and a pool that hands out a connection the server already dropped gives you an error instead of a query. TCP keepalives (keepAlive: true in Node, keepalives_idle=60 in a libpq connection string) stop NAT and firewalls from silently killing idle connections.
Run PgBouncer on the VPS
If your app can’t hold connections itself (PHP, CGI-style scripts, many short-lived workers, serverless functions on the same box), run PgBouncer on the VPS. The app connects to 127.0.0.1, which is nearly free. PgBouncer keeps a few TLS connections to the remote database open and hands them out.
; /etc/pgbouncer/pgbouncer.ini
[databases]
app = host=db.your-provider.com port=5432 dbname=app
[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
default_pool_size = 10
server_idle_timeout = 300
server_tls_sslmode = verify-full
server_tls_ca_file = /etc/ssl/certs/ca-certificates.crt
max_prepared_statements = 100
Your app’s connection string becomes postgres://app:[email protected]:6432/app. That is a short, local hop, so the TLS setup to the provider happens once rather than on every request.
Warning
Transaction mode limits: in pool_mode = transaction, anything tied to a session breaks: SET without LOCAL, LISTEN/NOTIFY, session-level advisory locks, and temp tables used across transactions. Use session mode for apps that need those. Prepared statements work in transaction mode on PgBouncer 1.21+ with max_prepared_statements set. If your provider already runs a transaction pooler, the same limits apply there.
Step 4: Make fewer round trips
Once connections are reused, what’s left is the number of sequential queries per request. This is where the biggest wins in slow apps usually hide.
Kill N+1 queries
The classic pattern: load 20 posts, then loop and load each post’s author. That’s 21 round trips. Load the authors in one query instead:
SELECT * FROM users WHERE id = ANY($1::int[]);
Every ORM has a way to do this, and none of them do it by default:
- Django:
select_related()for foreign keys,prefetch_related()for reverse and many-to-many. - Rails:
includes(:author). - Laravel:
with('author'). Turn onModel::preventLazyLoading()in development to catch the rest. - SQLAlchemy:
selectinload()orjoinedload(). - Prisma, Drizzle, TypeORM:
include/with/relations.
Collapse writes into single statements
- Insert and read back in one go:
INSERT ... RETURNING *instead ofINSERTfollowed bySELECT. - Upsert instead of check-then-write:
INSERT ... ON CONFLICT (key) DO UPDATEreplaces aSELECTplus anINSERTorUPDATE. - Many rows at once: one multi-row
VALUES (...), (...), (...)orunnest()instead of an insert per row. For thousands of rows, useCOPY. - Dependent writes in one statement: a data-modifying CTE creates an order and its line items in one round trip:
WITH new_order AS (
INSERT INTO orders (user_id, total)
VALUES ($1, $2)
RETURNING id
)
INSERT INTO order_items (order_id, product_id, qty)
SELECT new_order.id, item.product_id, item.qty
FROM new_order, unnest($3::int[], $4::int[]) AS item(product_id, qty)
RETURNING order_id;
A single statement is atomic on its own, so this also replaces the BEGIN / INSERT / INSERT / COMMIT sequence, which costs four round trips.
Watch transactions
Each statement in an explicit transaction is a separate round trip, including BEGIN and COMMIT. A transaction with 5 queries costs 7 trips. On top of that, row locks stay held while the packets travel, so slow transactions also cause contention under load. Keep transactions short, do your HTTP calls and computation before BEGIN, not inside it, and check whether your ORM wraps every save in its own transaction (many do).
Run independent queries at the same time
If a page needs the user, their notifications and a product list, and none depends on the others, don’t await them one by one:
const [user, notifications, products] = await Promise.all([
pool.query("SELECT * FROM users WHERE id = $1", [userId]),
pool.query("SELECT * FROM notifications WHERE user_id = $1 LIMIT 20", [userId]),
pool.query("SELECT * FROM products WHERE featured LIMIT 12"),
]);
Three round trips become one, at the cost of using three pool connections briefly. Some drivers can also pipeline several queries on one connection without waiting for each reply: libpq’s pipeline mode (Postgres 14+), psycopg 3’s conn.pipeline(), and pgx’s Batch in Go. Test pipelining through your provider’s pooler before relying on it.
Find the chatty code
- Count queries per request in development: Django Debug Toolbar, Laravel Debugbar, Rails logs, or your ORM’s query logging. Any endpoint with more than ~10 queries is worth a look.
- On the database: enable
pg_stat_statements(most managed providers support it) and sort bycalls. An N+1 shows up as one query shape with an enormous call count and a tiny average time.
Step 5: Don’t go to the database at all
The fastest round trip is one you skip.
- Cache data that’s allowed to be stale: settings, feature flags, category trees, a product list that changes daily. An in-process cache with a short TTL, or Redis/Valkey on the same VPS, costs microseconds instead of milliseconds.
- Cache whole responses for anonymous pages, at the reverse proxy or a CDN.
- Use a read replica near the VPS if your provider offers replicas in other regions. Reads become local, writes still go to the primary. Watch out for replication lag: don’t read from the replica right after a write the user expects to see.
- Don’t cache what the user just changed. Read-your-own-writes bugs are worse than a few milliseconds.
Don’t forget security and egress
Your database traffic now crosses the public internet, so:
- Use
sslmode=verify-full, notrequire.requireencrypts but doesn’t check who you’re talking to. With libpq 16+,sslrootcert=systemuses the OS certificate store. - Allowlist your VPS’s IP in the provider’s network settings if they support it, and keep the database closed to everyone else.
- Check egress pricing. Some managed providers charge for data leaving the database. Select only the columns you need, paginate, and don’t pull whole tables into the app to filter them there.
When a remote database is the wrong choice
Some workloads don’t survive a network hop, no matter how tidy the code is:
- Chatty code you can’t change: a legacy app or a third-party admin panel that runs 100 queries per page.
- Heavy write workloads with many small transactions, where each transaction holds locks for several round trips.
- Sub-millisecond requirements, such as high-frequency internal APIs and some game backends.
- Heavy use of session features (
LISTEN/NOTIFY, advisory locks, temp tables) behind a transaction pooler.
The fix there is to put the app and the database back in one place:
- Managed database from your VPS provider. Many VPS hosts sell managed Postgres in the same datacenter, reachable over a private network. Compare options in our managed Postgres hosting guide.
- Self-hosted Postgres on the VPS or a neighbour server. Sub-millisecond queries, flat pricing, and you own backups, upgrades and monitoring. See best VPS for PostgreSQL and the VPS backup guide.
- Move the app to the managed provider’s platform or to a VPS in the same cloud region as the database.
Checklist
- VPS and database in the same city, confirmed with
mtrandSELECT 1from the VPS. - No connection per request: app-level pool, Django
CONN_MAX_AGE, or local PgBouncer. - Pool idle timeout of minutes, not seconds, below the provider’s idle limit, with TCP keepalives on.
- N+1 queries found (
pg_stat_statements, query logging) and replaced with eager loading orANY($1). - Writes use
RETURNING,ON CONFLICT, multi-row inserts and CTEs instead of extra round trips. - Transactions short, with no external calls inside them.
- Independent queries run concurrently.
- Read-mostly data cached on the VPS.
-
sslmode=verify-fulland an IP allowlist.
The bottom line
A VPS with a managed Postgres from another provider adds a few milliseconds to every round trip. You can’t remove that, but you can make sure you pay it a handful of times per request instead of fifty. Put both in the same city, keep connections open, and stop issuing queries in loops. Most apps then land within a few milliseconds of a local database. If yours still doesn’t, it’s telling you the database belongs next to the app.
Ready to get started?
Get the best VPS hosting deal today. Hostinger offers 4GB RAM VPS starting at just $5.99/mo.
Get Hostinger VPS — $5.99/mo// up to 70% off + free domain
// related topics
// related guides
Best Managed PostgreSQL Hosting in 2026
Compare the best managed PostgreSQL hosting providers in 2026. Neon, Supabase, AWS RDS, PlanetScale, and self-hosted options ranked by pricing, features, and performance.
guideBest Static Website Hosting in 2026: Free Options vs VPS
Compare the best static website hosting options in 2026. Free platforms like Cloudflare Pages and GitHub Pages vs cheap VPS hosting for when you need more control.
guideHow to Run Claude While You Sleep (24/7 AI Assistant)
Set up Claude to work autonomously overnight. Automate tasks, monitor systems, and wake up to completed work using OpenClaw on a VPS.
Andrius Putna
I am Andrius Putna. Geek. Since early 2000 in love tinkering with web technologies. Now AI. Bridging business and technology to drive meaningful impact. Combining expertise in customer experience, technology, and business strategy to deliver valuable insights. Father, open-source contributor, investor, 2xIronman, MBA graduate.
// last updated: October 8, 2026. Disclosure: This article may contain affiliate links.