Managed Postgres With Your Own VPS: How to Live With the Latency
GUIDE14 min readAndrius Putna

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

Where the time goes

A query from your VPS to a managed database pays for:

  1. 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.
  2. 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.
  3. 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.
  4. Connection setup, if you open a new connection: several extra round trips before the first query (see below).
  5. 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 areTypical round trip
Same server or same datacenter0.1–1 ms
Same city, different providers0.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 West60–75 ms
US East ↔ Western Europe75–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 request1 ms RTT5 ms RTT20 ms RTT80 ms RTT
11 ms5 ms20 ms80 ms
1010 ms50 ms200 ms800 ms
5050 ms250 ms1 s4 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:

StepRound trips
TCP handshake1
Postgres SSLRequest1
TLS 1.3 handshake1
Startup message and auth request1
SCRAM-SHA-256 authentication exchange2

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.

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:

StackDefaultFix
Node (pg)Whatever you write. A new Client() per request reconnects every time.One Pool per process, created at startup.
DjangoCloses 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.
RailsPooled per process.Fine. Check pool matches your thread count.
Go (database/sql)Pooled, but keeps only 2 idle connections.SetMaxIdleConns close to SetMaxOpenConns.
SQLAlchemyPooled (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:

Collapse writes into single statements

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

Step 5: Don’t go to the database at all

The fastest round trip is one you skip.

Don’t forget security and egress

Your database traffic now crosses the public internet, so:

When a remote database is the wrong choice

Some workloads don’t survive a network hop, no matter how tidy the code is:

The fix there is to put the app and the database back in one place:

Checklist

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.

~/managed-postgres-vps-latency/get-started

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

managed postgres vps latencyremote postgres database latencypostgres connection pooling vpspgbouncer on vpspostgres n+1 queries latencymanaged database different provider

// related guides

Andrius Putna

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.