← BLOGHOME↗

Jun 11, 2024 · 6 min read · Production, Reliability, and Scale

Scaling a Multi-Tenant SaaS Product

A growing SaaS product didn’t immediately need more services. First, we needed to know which workload was growing and where the current system was reaching a limit.

While building a multi-tenant SaaS product as a freelancer, I owned work across the backend, database, deployment, and production support. As more workflows were added, “Will this scale?” started appearing in design conversations. We considered separating the application into services, changing the database, moving slower work to queues and preparing a more complex cloud architecture. Those options meant very little until we understood which workload was growing and where the current system was reaching a limit.

What Was Actually Growing?

Different users create different pressure. One may read a small amount of data. Another may generate a report across months of activity. A background job may process more work than hundreds of ordinary requests. A single poorly bounded query can affect the database more than a large number of lightweight endpoints. We looked at the important access patterns rather than treating all traffic as equivalent. Some requests were fast and stateless. Some needed careful database indexes. A few longer operations were better handled in background jobs so they didn’t occupy the user-facing request path. The system didn’t have one general scaling problem. It had different workloads with different needs.

Multi-tenancy made the difference between those workloads larger. Most tenants had a similar amount of data, but a few used the product more heavily or kept a longer history. A query that behaved well for the average tenant could become slow for the largest one. We tested those cases separately and avoided using only the total request count as a capacity measure.

We also looked at when the work happened. Some background jobs ran on a schedule and created a predictable peak even when user traffic was normal. Moving them to more workers without limits could compete with the API for database connections. We adjusted the schedule and concurrency before adding infrastructure because the system had enough capacity when the work wasn’t all trying to run at the same moment.

The Existing Architecture

The product used Node.js, TypeScript, NestJS, PostgreSQL, and AWS. A modular application gave us enough separation to improve specific areas without immediately distributing the entire system. We could increase application capacity when request volume required it, optimize the database where data access was the limit, and move appropriate work to background processing. This kept the operational model understandable while the product was still evolving. Separating everything into services early would have created more deployments, network communication, monitoring, and decisions about data ownership before the workload justified them. It might look more scalable in a diagram while making our small team slower.

The modules still had defined responsibilities so we could move one later if there was a reason. Database access and business behavior weren’t allowed to spread through every controller only because the application had one deployment. This gave us most of the development simplicity of a monolith while keeping the expensive workflows identifiable.

For background work, the API stored the operation and returned without waiting for the complete report or integration flow. Workers processed the jobs with concurrency limits and updated their state. This improved the user-facing response and allowed the longer workload to scale separately inside the same overall product. We accepted the additional queue and worker process for work we had already measured.

Capacity and Efficiency

Scaling infrastructure increases how much work a system can perform. Improving efficiency reduces the work required for each operation. Both matter. If a query reads unnecessary data, adding database capacity delays the problem. If one background task has no concurrency limit, adding more workers can overload the next dependency. If the application stores large responses repeatedly, increasing memory doesn’t question why the responses are large. We tried to understand efficiency before increasing capacity. When additional capacity was appropriate, we could add it with a clearer idea of what improvement to expect. It’s reasonable to design for growth. The danger is paying today for every future architecture the product might need. We kept boundaries clear, avoided hiding business logic inside framework code, and made longer work separable from the request path. Those decisions preserved options without requiring us to build every option immediately. Early in a product, I value the ability to change the system when we have evidence more than an architecture built for every possible future.

PostgreSQL was usually the first shared resource we watched because the API and background jobs both depended on it. We reviewed the heaviest queries, added indexes for the real tenant filters and kept pagination mandatory on endpoints where a tenant’s history could continue growing. We also set limits around reports that would otherwise try to load an unbounded amount of data into memory. These changes weren’t an alternative to scaling the database forever, but they made the capacity we already had useful for more work.

We cached a small number of values that were read frequently and changed less often, but we didn’t add caching around every slow response. For tenant-specific data, invalidation and isolation needed to be correct, and a cache could easily serve old information or hide the query we still needed to fix. We used it where the lifecycle was clear enough that the additional state was easier to operate than the original repeated work.

Watching the Limits

I tracked response times, database connection usage, job duration and the age of pending work. These signals helped separate a short traffic peak from a workload that was steadily growing beyond the current capacity. They also showed where adding resources would help. If the workers were waiting on an external integration, starting more of them might increase requests without completing jobs any faster.

We increased capacity as usage grew, after identifying which component needed it and what result we expected. The limits became visible at different times as people used the product, so the scaling work happened through several smaller decisions.

Because I was responsible for both building and operating the product, every extra component felt concrete. A new service meant another deployment and another place I’d eventually troubleshoot. We did move some longer work to background jobs and increased capacity where the measurements supported it, but we kept most of the product in a modular application. The architecture still looked simple, and for the stage of the product that was useful. Most of our scaling conversations became much easier after we stopped talking about “more users” and started talking about the specific query, job or integration that was growing.

The boundaries and measurements remained there if the product later needed separate services or a different data architecture. At the stage I worked on it, a small team could still operate the product without carrying that future architecture years before it was needed.