In early 2025, we were running a SaaS platform for a logistics company processing roughly 12,000 API calls per hour. The product worked. The code passed every review. The test coverage was respectable. Then the client doubled their operational territory — and the whole thing fell over in three days.
This isn't a post about fixing bugs. It's about the architectural assumptions we made when building for "current scale" that made the system fundamentally brittle at the next order of magnitude. Here's what we found, what we changed, and the specific numbers that justified each decision.
The Monolith Problem Is Not What You Think
Everyone assumes the fix is "go microservices". That's wrong. The problem wasn't that we had a monolith — it was that our monolith had no internal boundaries. Every domain bled into every other domain through shared database tables and synchronous function calls. Billing logic queried the same connection pool as tenant provisioning. Authentication middleware ran a fresh DB query on every single request.
When load tripled, the connection pool saturated. When the pool saturated, every request waited. When every request waited, timeouts cascaded. The monolith wasn't the problem — the lack of isolation was.
The root failure mode isn't architectural style — it's the absence of resource isolation between domains. A well-structured monolith outperforms a poorly designed microservices system every time.
— Rahul Nair, Lead EngineerWhat We Actually Changed
1. Read Replica Routing
Our first win was the simplest. We added a read replica and routed all non-mutating queries to it. This sounds obvious in retrospect. Roughly 73% of our queries were reads — analytics dashboards, reporting endpoints, list views. Offloading them to the replica immediately halved the write-primary's CPU utilisation and cut median response times by 60% before we touched a single line of business logic.
// Before: every query hits the primary const result = await db.query('SELECT * FROM orders WHERE tenant_id = $1', [id]); // After: reads route to replica automatically const result = await db.replica().query('SELECT * FROM orders WHERE tenant_id = $1', [id]); // Writes still go to primary await db.primary().query('INSERT INTO orders ...', [orderData]);
2. Event-Driven Domain Isolation
The deeper fix was introducing Redis Streams as an event bus between internal domains. Instead of the billing module calling the tenant module synchronously, billing emits an event. The tenant module consumes it asynchronously. The domains are now decoupled at the I/O boundary. A spike in billing processing no longer blocks tenant provisioning, and vice versa.
We didn't rewrite the entire system. We identified the five highest-latency cross-domain call chains, extracted them to events, and re-deployed. The whole exercise took two engineers eleven working days.
3. Aggressive Connection Pool Tuning
Our default pg pool was set to 10 connections per process instance. We were running four instances. At 10× load we were spawning retry threads faster than connections were being released. We profiled every query, killed three that were genuinely unbounded, added a 5-second statement timeout to catch regressions, and tuned the pool to 25 connections with pgBouncer in front at the infrastructure layer. Connection wait time dropped from 800ms average to 4ms.
The Numbers That Justified Each Decision
Engineers often skip this part. Don't. Every architectural change has a cost — engineering time, deployment risk, operational complexity. You need numbers to make the case internally and to know which intervention deserves priority.
What We'd Do Differently From Day One
If we were starting this product again, here's what changes in the first two weeks of engineering:
- Read replicas from week one. The cost is negligible at small scale. The operational habit of routing reads correctly is worth establishing early.
- Define domain boundaries in the data model. Even in a monolith, tables should have clear owners. Cross-domain queries should be rare, explicit, and monitored.
- Instrument everything before optimising anything. We wasted two days chasing a suspected N+1 in the wrong module. The actual bottleneck was connection wait time, which we only found when we added proper OpenTelemetry traces.
- Set statement timeouts on day one. An accidental full-table scan on a production database is a three-hour incident. A statement timeout makes it a logged warning.
Closing Thought
Scale failures are rarely about the technology stack. They're almost always about the assumptions baked into the design at a point when scale didn't matter. The fix isn't a rewrite — it's isolation, observability, and the patience to profile before you build.
If you're running a SaaS product and noticing latency climbing as you grow, the patterns here likely apply. We're happy to do a free architecture review — link at the bottom.