Every project that outgrows SQLite arrives at the same fork. You can run Postgres on the machine your application already lives on, which costs nothing extra and takes about four minutes, or you can pay for a managed one and point a connection string at it. The advice you get is either "managed, obviously, do you want to be a DBA?" or "managed is a tax on people who cannot read documentation", and neither camp produces numbers.
So I built one application, gave it two identical databases, and measured it properly. Postgres 17.11 on both sides, same schema, same 200,000 rows, same region, same hour. One of them lives on the application's own droplet, the other is DigitalOcean's smallest managed instance.
The results were not what I expected in either direction, and the most useful thing I learned came from a benchmark that turned out to be measuring nothing at all.
The setup
The application is a small FastAPI service with three endpoints: a single indexed row lookup, a range scan with an aggregate, and an insert. Those cover most of what a web backend actually asks a database to do. It uses a psycopg connection pool, runs behind uvicorn, and the only thing that changes between runs is the connection string.
The database is 200,000 rows of a products table, about 25MB. Small, deliberately, because I wanted to measure the plumbing rather than disk behaviour.
One important detail before any numbers: Ubuntu ships Postgres 16 and DigitalOcean's managed instance was 17. Benchmarking those against each other would have been comparing two variables at once, so I installed Postgres 17 from the PGDG repository on the droplet. Both sides ended up on 17.11.
The first benchmark measured my own web framework
I ran the load generator from a second droplet in the same region, hit /item with rising concurrency, and got this:
| connections | local Postgres | managed Postgres |
|---|---|---|
| 10 | 597 req/s | 482 req/s |
| 50 | 508 | 571 |
| 100 | 368 | 538 |
| 200 | 428 | 556 |
My first reading was that local wins when things are quiet and managed wins under load, which is a tidy story about CPU contention: the local database competes with the application for the same two cores, while the managed one has its own machine.
Then I looked at where the CPU was actually going during a run.
uvicorn cpu=72.7%
postgres cpu=9.1%
Postgres was doing almost nothing. The single Python process was the bottleneck in every one of those eight measurements. I had spent an hour building a careful comparison of two databases and had in fact measured the throughput of FastAPI and psycopg, which is the same regardless of what is on the other end of the socket.
That is worth sitting with if you are planning your own comparison. A web application in front of a small database is usually not database-bound at all, so if you benchmark through it you will conclude that your database choice does not matter, which is true in that setup and tells you nothing whatsoever about the databases, since what you actually measured was how fast your language runtime can serialise a response.
The real comparison
pgbench talks to Postgres directly, with no application in the way, so that is what the comparison needed. Same machine issuing the queries, same twenty-second runs, read-only and read-write:
Read-only transactions per second
| clients | local | managed |
|---|---|---|
| 1 | 4,161 | 611 |
| 4 | 8,316 | 2,026 |
| 8 | 8,702 | 1,316 |
| 16 | 7,141 | 1,985 |
Read-write transactions per second
| clients | local | managed |
|---|---|---|
| 1 | 215 | 127 |
| 4 | 658 | 260 |
| 8 | 654 | 343 |
| 16 | 691 | 363 |
Between four and seven times the read throughput, and about twice the write throughput, for the database on the same box.
The mechanism is not mysterious and it is visible in the single-client latency: 0.240ms for local, 1.637ms for managed. That gap of roughly 1.4 milliseconds is the network hop plus TLS, paid on every single query. When a query itself takes a quarter of a millisecond, adding 1.4ms of travel makes the round trip six times longer, and no amount of database tuning touches it.
This is the part that surprised me most, because it is a much larger difference than the discussion usually implies. If your workload is many small fast queries, and most web workloads are, a managed database on the other side of a network interface is a fundamentally different performance profile rather than a slightly slower one.
Then I found the ceiling
While poking at the managed instance I checked something I had not thought about:
max_connections : 25
connections in use : 11
Twenty-five. The local Postgres, with Ubuntu's untouched defaults, allows 100. And eleven of the managed twenty-five were already taken by the platform's own monitoring and maintenance connections before my application opened a single one.
So I ran pgbench against the managed database with rising client counts, and then again through the PgBouncer pool that DigitalOcean will create for you through the API:
| clients | direct | through the pool |
|---|---|---|
| 8 | 2,022 tps | 1,416 tps |
| 20 | failed | 1,399 tps |
| 40 | failed | 1,161 tps |
| 80 | failed | 1,276 tps |
Direct connections stop working somewhere around twenty clients, which is exactly where you would predict given fourteen usable slots, and the failure mode is a refused connection rather than a slow one, so the application does not degrade gracefully at that point, it starts throwing errors.
The pooler costs about thirty per cent of throughput at low client counts, and it is the difference between an application that works and one that does not as soon as you run more than one process. That trade is the actual product here, and it is a good one, but it is on by choice rather than by default and I only went looking because a number surprised me.
What the money buys
The droplet running both application and database is $24 a month. Adding the smallest managed database is another $15, so roughly a sixty per cent increase for a setup that, by the measurements above, is several times slower on raw queries.
Six minutes after the instance existed, it had already taken a backup, which I confirmed through the API rather than the dashboard. It has a maintenance window scheduled for Saturday night, it will apply its own patches, and the pooler is one API call away.
None of that shows up in a throughput table, and all of it is the reason people pay. The honest framing is that you are not buying performance, you are buying the elimination of a category of work: backup scripts you have not tested, the Postgres minor version you have not applied, and the 3am discovery that your disk filled up with WAL. If you have ever restored from a backup you took yourself, you know that the interesting question is not whether the backup ran.
What I would actually do
If the application and the database are both small and both live on one machine, running Postgres locally is dramatically faster, and the reason is latency you cannot optimise away. For a side project, an internal tool, or anything where a lost afternoon is survivable, that is a perfectly respectable choice and the performance is not close.
The moment there is more than one application server, the calculation inverts. You cannot co-locate a database with three machines, and once it is across a network you are paying the 1.4ms regardless, so you may as well have the version that backs itself up. That is also the moment the connection ceiling arrives, because three servers with a pool of twenty each is sixty connections against a limit of twenty-five, and the pooler stops being optional.
The thing I would tell my earlier self, though, is the methodological one. My first eight measurements were careful, consistent, reproducible and completely meaningless, because the component I was varying was responsible for nine per cent of the CPU. Before comparing two backends, check that the thing you are changing is the thing under load. It took one top command, and I ran it an hour later than I should have.
Everything here ran in Frankfurt on a $24 droplet, a second droplet as a load generator, and a $15 managed instance, all destroyed afterwards. The whole exercise cost a few cents.
Top comments (0)