
A behind-the-scenes look at optimizing PostgreSQL. From connection leaks in Kubernetes, indexing overkill that killed bulk inserts, to stale cache disasters with Redis.
There is a classic myth among developers: "If your app is lagging, just scale up the database server."
At my previous company, I happened to be the developer who managed everything end-to-end, from the frontend to the initial server deployment. So yes, database and infrastructure management fell into my hands as well.
I was tempted by this shortcut too. When my PostgreSQL database at that company started choking under the load of Kubernetes pods and automation workflows, my first reaction was to open the cloud dashboard, click on the resources, and double the CPU cores from 2 to 4, and RAM from 2GB to 4GB.
The result? The app was still slow, and occasionally threw FATAL: too many clients already errors. Meanwhile, the database CPU usage was sitting comfortably under 10%. That was the exact moment I realized I had just performed a foolish over-provisioning workaround to mask a flawed database architecture.
Database optimization isn't about how much RAM you throw at the server; it's about how efficiently your database engine runs under the hood. Here are some of the "engineering scars" and expensive lessons I gathered from tuning database performance in the production environments of my previous company.
This was the first architectural mistake I made. In my company's Kubernetes cluster, I used Drizzle ORM for several microservices. By default, every time an application pod spins up, Drizzle spawns its own connection pool (usually defaulting to 10-20 connections per instance).
As traffic grew and Kubernetes scaled my application pods from 2 replicas to 10, the PostgreSQL database instantly choked due to connection exhaustion (connection limit exceeded).
max_connectionsMy second mistake was simply raising max_connections in Postgres config from 100 to 500. Do not do this. Every active connection in Postgres spawns a physical backend process (forked process) in the OS, consuming around 10MB of RAM per connection. With 300+ active connections, your database will spend more time handling CPU context switching between processes than executing the actual queries.
Eventually, I deployed PgBouncer as a connection pooler in front of Postgres. PgBouncer keeps a pool of warm connections to the database, and our application only talks to PgBouncer.
However, setting up PgBouncer has its own traps:
Everyone knows that if a SELECT query is slow, you should throw an index at it. But I once got burned by being too index-happy.
On a transaction log table that ingests thousands of rows daily, I confidently created B-Tree indexes on almost every column I filtered (status, created_at, user_id, amount, gateway).
Read queries became blazingly fast (under 5ms). However, when the system started running workflows that bulk-inserted thousands of transaction records at once, the database server CPU spiked to 100% and the insert rate slowed to a crawl.
Why? The Write Tax. Every time a new row is inserted, Postgres has to update the B-Tree structure for every single index I created.
EXPLAIN ANALYZE: Stop guessing. Always run EXPLAIN ANALYZE SELECT ... before creating an index. Check whether the query planner actually performs an Index Scan or if it defaults to a Seq Scan (Sequential Scan) because the table is too small or the column cardinality is too low.Beyond infrastructure, how we write queries often makes the database suffer. Here are two antipatterns I regularly find (and fix):
SELECT * DiseaseI used to be lazy and write SELECT * in raw queries or ORM models to avoid writing column names one by one. In production, this is dangerous.
SELECT * also prevents the query planner from performing an Index-Only Scan (retrieving data directly from the index without reading the actual table row).This is a common issue with modern ORMs. You want to fetch a list of 50 transactions and display their user details. If you don't use Eager Loading or Joins, your ORM will run 1 query to fetch the transactions, followed by 50 separate queries to fetch the user details for each transaction. Your database spends unnecessary resources handling network roundtrips.
The fastest database query is the one you never make. At my previous company, I set up Redis as a caching layer in front of the database for data that is frequently read but rarely changed (like product catalogs or system configurations).
I implemented the Cache-Aside pattern: the app checks Redis -> if found (Cache Hit), return immediately -> if not found (Cache Miss), query the database -> write to Redis -> return to user.
But caching has a major pitfall: Cache Invalidation. I once forgot to set a TTL (Time-To-Live) and failed to clear the Redis cache when a new transaction occurred that updated the user's balance.
The result? The user paid via Midtrans, and their balance was updated in Postgres, but their dashboard still showed zero because the application was reading stale data from Redis. The user panicked and complained to customer support.
Lesson: Always set a realistic TTL (never cache indefinitely unless you have a robust cache invalidation pipeline), and make sure you run a DEL command or update the Redis cache in the same database transaction where the data changes.
To save yourself from a 3:00 AM server crash, here is a quick database optimization checklist:
EXPLAIN ANALYZE.SELECT * from your production queries.Your database is the heart of your application. Treat it with efficient queries and a clean connection architecture, rather than blindly scaling up the RAM capacity on your cloud provider's dashboard. wkwk