Why Your Database is Slower at 3 PM Than 3 AM (And What I Did About It)

The Lunch Rush Problem

Three months ago, I was staring at graphs that made no sense. Our PostgreSQL database hummed along beautifully at night, processing queries in under 50ms. Come 2 PM, those same queries were taking 3 seconds. The operations team kept adding RAM. The business kept complaining. I kept wondering why nobody had taught me about connection pool exhaustion in computer science class.

The real kicker? Our traffic barely doubled during peak hours. A 2x increase in load was causing a 60x degradation in performance. This isn’t a story about scaling to Facebook levels. This is about that moment when you realize your database is held together with wishes and index prayers, and lunch hour just exposed every architectural sin you’ve been ignoring.

Connection Pools Aren’t Magic Boxes

Let me start with the most expensive lesson. We were using HikariCP with default settings, which gave us 10 connections. Our application servers were spinning up 20 threads during peak load. Do the math. Eighteen threads were sitting around waiting for database connections like customers at the DMV, while our CPU graphs looked perfectly healthy.

The fix wasn’t throwing more connections at the problem. I bumped the pool to 15 connections and added connection timeout monitoring. More importantly, I started tracking connection utilization patterns. Turns out, our report generation queries were hogging connections for 30+ seconds while user login attempts queued behind them. A simple read replica for reporting traffic cut our peak response times from 3 seconds to 200ms.

Here’s what actually works: set your connection pool size to match your expected concurrent database operations, not your thread count. Monitor connection wait times religiously. And for the love of all that’s holy, don’t let reporting queries run on your primary database during business hours.

Query Plans That Look Good Until They Don’t

PostgreSQL’s query planner is impressively smart until your data grows up. We had a join query that flew through our development dataset of 10,000 rows. Six months later, with 2 million rows, that same query was doing sequential scans and hash joins that consumed our entire shared buffer cache.

The problem wasn’t missing indexes. We had indexes. The problem was that our compound index on (user_id, created_at, status) wasn’t being used because the query planner decided a sequential scan was cheaper. It was wrong. Adding ANALYZE statements to our deployment pipeline and bumping random_page_cost from 4.0 to 1.1 (because we’re on SSDs) convinced the planner to trust our indexes again.

Pro tip: EXPLAIN ANALYZE is your best friend, but only if you run it against production-sized data. That query plan that looks perfect in staging might be planning for a completely different universe than what your users experience at 3 PM on a Tuesday.

The Art of Strategic Denormalization

Database normalization rules are great until you’re joining eight tables to display a user’s dashboard. We had a beautifully normalized schema that required six JOINs to show a user’s recent activity. Each join was fast. The combination was death by a thousand cuts, especially when multiplied by hundreds of concurrent users.

I created a denormalized activity_summary table that aggregated the most frequently accessed data. Yes, it violated third normal form. Yes, it required trigger functions to maintain consistency. Yes, it cut our dashboard load time from 800ms to 45ms and probably saved my weekend.

The maintenance overhead was worth it because this wasn’t academic purity. This was production reality. Sometimes the elegant solution is admitting that your perfectly normalized schema doesn’t match how your application actually queries data. Cache invalidation is hard, but not as hard as explaining to your CEO why the dashboard times out during board meetings.

Indexes That Actually Get Used

Adding indexes feels like free performance until you realize every INSERT is now maintaining seventeen different data structures. We had fallen into the classic trap: whenever a query was slow, we’d add an index. Six months later, we had 40+ indexes on our core tables and INSERT performance had quietly degraded to the point where bulk data imports were taking hours.

I spent a weekend auditing our indexes using pg_stat_user_indexes. Twelve of our indexes had never been used. Ever. Another eight were redundant because PostgreSQL was using more selective compound indexes instead. Dropping the unused indexes improved our write performance by 30% and reduced our database size by 15%.

The lesson: monitor index usage as aggressively as you monitor query performance. That index you created for a report six months ago might still be there, slowing down every INSERT while providing zero value. Sometimes the best performance optimization is deleting code, not adding it.

What Actually Matters When Your Phone Rings

After three months of optimizing everything from vacuum schedules to shared buffer sizes, here’s what moved the needle: connection pool management, query plan analysis, strategic denormalization, and ruthless index auditing. The fancy stuff like partitioning and read replicas came later.

The real insight wasn’t technical. It was realizing that database performance problems are rarely database problems. They’re application architecture problems that show up in the database. Your ORM might be generating garbage queries. Your connection pool might be sized for a different century. Your perfectly normalized schema might not match how users actually use your application.

What performance problems are hiding in your 3 PM traffic patterns?

Related Post