TL;DR — Fifteen backend mistakes made scaling a multi-tenant Node.js SaaS to 20,000+ users, and every one of them was a reasonable decision at the size it was made. The pattern underneath most of them: I trusted something that was only true at small scale — that the query was fast, that the list was short, that the request would arrive once, that the third-party API would answer. The expensive ones were no pagination, indexes added under pressure instead of by design, heavy work inside the request cycle, trusting the frontend, and underestimating concurrency. Each is cheap to prevent and costly to retrofit.
Most engineering blogs describe the architecture that worked. Far fewer talk about the decisions that nearly broke production.
Over the last few years I’ve built and scaled SaaS products used by thousands of people, including a multi-tenant food-tech platform that now serves 20,000+ users and processes around 50,000 meals every month. Along the way I made plenty of decisions that looked perfectly reasonable during development and turned painful once real traffic showed up.
This isn’t a list of theoretical best practices. These are 15 mistakes I actually made while scaling a Node.js SaaS, and the lesson I took away from each one. If you’re earlier on the same path, most of these are cheap to avoid now and expensive to fix later.
1. Assuming Queries Were Fast Because the Database Was Small
In the early days, everything felt fast. The database held a few thousand rows and most API responses came back in under 100ms, so query performance simply wasn’t on my radar.
Then usage grew. A query that took 30ms against 1,000 rows started taking several seconds once the table crossed a few hundred thousand. The query hadn’t changed — my assumption that current performance would hold as data grew was the actual bug.
Lesson: Design queries for the scale you’re heading toward, not the data you have today. Read execution plans with EXPLAIN ANALYZE while the table is still small, so you understand how a query behaves before it becomes a production incident.
2. Fetching More Data Than Necessary
For convenience, a lot of my early endpoints returned entire rows:
SELECT * FROM orders;
It worked fine until mobile users started pulling large datasets over slow networks. Most screens only rendered four or five fields; the rest of the payload was downloaded, parsed, and thrown away.
Lesson: Select only the columns the client actually uses. Payload size affects perceived performance far more than most developers expect, and SELECT * quietly couples your API to every future schema change. I went deeper on this in Scaling PostgreSQL in Production.
3. Not Implementing Pagination Early
One dashboard endpoint returned every order for a venue. It felt harmless — some venues had only a few dozen orders. Months later, the busiest venues had thousands, and that single endpoint was slowing the API, bloating the frontend, and spiking database load all at once.
Lesson: Every list endpoint should support pagination from day one, even when you’re sure you’ll never need it. Retrofitting pagination into a shipped API and its clients is far more work than building it in from the start.
4. Ignoring Indexes Until Production
This is probably the most common scaling mistake there is — and I made it. Indexes only got added after users complained about slow pages, by which point several hot queries were doing full table scans and diagnosing them under pressure was much harder.
Lesson: Index frequently-filtered columns before performance degrades. Usual suspects: foreign keys, status columns, timestamps, and the composite filters behind your most common access patterns. Composite indexes that match real query shapes almost always beat a pile of single-column indexes — note that a multicolumn index is only usable for a query that constrains its leading column, which is why the column order has to match how you actually filter.
5. Running Heavy Business Logic Inside the Request Cycle
Some endpoints did everything in a single request: validation, reporting calculations, aggregations, and outbound third-party calls. The result was predictable — long response times, occasional timeouts, and a poor experience exactly when the system was busiest.
Lesson: API requests should return quickly. Push heavy or slow work — notifications, report generation, external calls — into background jobs and queues. Decoupling non-critical work from the request path was one of the biggest wins in scaling the platform through its lunch-rush peaks.
6. Trusting the Frontend Too Much
Early versions leaned heavily on frontend validation. That held up right until people started sending requests straight to the API, bypassing the UI entirely. Malformed payloads got through, and data inconsistencies followed.
Lesson: Client-side validation is a UX nicety, not a security boundary. Validate everything again on the server — every time, no exceptions. This is the cheap half of what OWASP files under broken access control, which has been their number one application risk for years precisely because the missing check is always the one somebody assumed happened upstream.
7. Logging Everything
At one point I logged nearly every request and response. It felt thorough. Then logs started eating storage, costing money, and burying the one line that actually mattered under thousands that didn’t. When an incident finally hit, the noise made debugging slower, not faster.
Lesson: Log meaningful events, not raw volume. Good observability is about signal and relevance — structured logs, sensible levels, and metrics for the things you actually alert on.
8. Storing Files on the Application Server
Uploaded images originally landed on the application server’s local disk. Everything worked until deployments started wiping files, scaling to a second instance meant uploads existed on only one of them, and backups ballooned.
Lesson: User-generated files belong in object storage (S3 or similar), not on the box running your app. It keeps instances stateless, which is a prerequisite for scaling horizontally — something I leaned on heavily while trimming infrastructure cost.
9. Underestimating Concurrency
One of the most painful classes of bugs came from multiple users updating the same record at the same time. During peak hours, race conditions surfaced and orders occasionally ended up in states that “shouldn’t have been possible.” None of it reproduced in local testing, because local testing is single-user by default.
Lesson: Concurrency creates problems your test suite will never show you. Understand transactions and row-level locking, and design write paths for simultaneous access from the start. I wrote up a concrete pattern for this in Building an Atomic Delivery Dispatcher with PostgreSQL Row-Level Locking.
10. Writing Large Controllers
Some early controllers grew into hundreds of lines, with validation, business logic, database access, and response formatting all tangled in one file. They became hard to read, harder to change safely, and nearly impossible to unit test in isolation.
Lesson: Separate concerns early. Thin controllers, with logic pushed into services and data access into its own layer, pay for themselves the first time you need to change behavior without breaking three other things.
11. Building Reports Directly From Transactional Tables
Reporting dashboards initially queried the production transactional tables directly. As data and report complexity grew, those heavy analytical scans started competing with customer-facing traffic for the same rows and the same connections — so performance degraded for everyone the moment someone opened a report.
Lesson: Analytical and transactional workloads have different shapes and shouldn’t fight over the same resources. Move reporting to views, periodic rollups, materialized views, or a read replica so dashboards can’t drag down checkout.
12. Not Monitoring Slow Queries
For too long, performance problems were discovered the same way every time: a user reported them. By then the damage was already done and I was reacting instead of preventing.
Lesson: If users find problems before your dashboards do, your monitoring isn’t sufficient. Turn on pg_stat_statements before you need it — it aggregates execution counts and total time per normalised query, which turns “the app feels slow” into a ranked list. Track query duration, API p95 latency, error rates, connection-pool saturation, and memory — and alert on the metrics that predict failure, not just on CPU after it’s already on fire.
13. Assuming Third-Party APIs Are Reliable
Several critical workflows depended on external services. One day one of them went down, and our flow failed with it — even though our own system was perfectly healthy. We’d treated someone else’s uptime as if it were our own.
Lesson: External dependencies will fail. Wrap them in timeouts, retries with backoff, circuit breakers, and graceful degradation so a partner’s outage becomes a degraded feature instead of a full outage.
14. Skipping Load Testing
Everything ran beautifully in development — because development traffic isn’t production traffic. The first real spike exposed bottlenecks we’d never thought to look for. For a platform where most orders land inside a tight lunch window, that gap between “works on my machine” and “works at 15x average load” was enormous.
Lesson: Load testing surfaces problems code review never will. Simulate realistic, concentrated traffic in staging before launch, not after the first bad day.
15. Optimizing Too Late
This is the one that ties the rest together. Almost every issue above could have been prevented by a small, cheap improvement made early. Instead each one waited until it became an emergency fix during a period of growth — the worst possible time to be touching critical paths.
Lesson: Scaling isn’t a single milestone you hit at 20,000 users. It’s a continuous practice, and small architectural decisions compound — for you or against you.
Final Thoughts
The biggest misconception about scaling is that it begins when you reach tens of thousands of users. In reality it begins with your first architectural decision. Most production pain doesn’t come from exotic distributed systems — it comes from small shortcuts that felt harmless while the product was young.
If I were starting the same SaaS today, I’d invest much earlier in database design, query optimization, monitoring, background jobs, concurrency handling, and clean separation of concerns. The encouraging part is that every mistake on this list is fixable. The challenge is fixing each one before your users feel it — and that’s the difference between software that grows into its success and software that buckles under it.
FAQ
What is the most expensive scaling mistake in a Node.js SaaS? Not paginating list endpoints. Every other item on this list degrades gradually; an unpaginated endpoint works perfectly until one tenant’s data crosses a threshold, then fails hard for that tenant only. It is also the most expensive to retrofit, because the API contract and every client have to change together.
When should you add pagination to a list endpoint? Before it ships. Even when you are certain the list will stay short — the endpoint that returned a few dozen rows in year one is the one returning thousands in year two. Adding it later means versioning the response shape and coordinating a change across every consumer you have.
Why do indexes always get added too late? Because a small table makes every query look fast, so nothing signals the missing index until the table is large and the diagnosis has to happen under pressure. Read the execution plan while the table is small and the shape of the query is still cheap to change.
Can you catch concurrency bugs with your test suite? Almost never. Test suites are single-user by default, so two requests updating one row at the same instant is a scenario your tests do not create. You have to design write paths for simultaneous access up front — transactions and row locks — rather than expect a red test to tell you.
Should reporting dashboards query transactional tables directly? No. Analytical scans and customer-facing reads have different shapes and end up competing for the same rows and the same connection pool, so opening a report degrades checkout. Move reporting onto views, periodic rollups, materialised views or a read replica.
When should work move out of the request cycle? As soon as it is not required to produce the response. Notifications, report generation and outbound third-party calls all belong in a background job. Anything that makes your API’s latency depend on a system you do not control is a timeout waiting for your busiest hour.
The one idea to take away
Almost none of these were bad decisions — they were decisions with an expiry date nobody wrote down. Skipping pagination on a list of 40 rows is correct. It stays correct until the day it isn’t, and there is no alert for that day. The practical habit is not “do it properly from the start”, which is how you never ship; it is noting which shortcuts are load-bearing on your current size, so that when traffic changes you know where to look first.
If you want the detail behind individual items here: the database work is broken down separately, the platform-level view is here, and the concurrency mistakes have their own post.