From One Process to 1,000 Users: How I Scaled a Nigerian Marketplace API Without Rewriting It
There is a specific kind of dread that comes from looking at a production application you built and realising it would fall apart under any real load. Not because the code is bad, but because certain

There is a specific kind of dread that comes from looking at a production application you built and realising it would fall apart under any real load. Not because the code is bad, but because certain decisions that made sense during development — decisions you probably didn't even think of as decisions — quietly compound into a system that cannot scale.
That is where I found myself with an e-commerce marketplace I built for the Nigerian market. The app worked. Users could sign up, browse products, add to cart, check out, pay. But "worked" is doing a lot of lifting in that sentence. Worked for how many people, exactly?
I sat down and did an honest audit. The results were humbling.
What the Audit Found
The stack is NestJS for the API, Next.js for the frontend, PostgreSQL via TypeORM, Redis, and PM2 as the process manager. Reasonable choices across the board. The problems were not in what I chose but in how things were configured.
PM2 was running in fork mode. That means one Node.js process for the API. One. Every request queued behind whatever the previous one was doing. On a multi-core server, I was using exactly one core.
The database connection pool had 10 connections. At roughly 150 concurrent users who touch the database simultaneously, those 10 connections would be fully saturated and requests would start timing out waiting for a free slot.
Rate limiting was in-memory. This is a subtle bug. When you eventually switch to cluster mode (multiple processes), each process keeps its own counter. A user could make 100 requests per process before being throttled — meaning effectively unlimited if you have four workers.
Product listings had an N+1 query problem. I had set eager: true on the variantQuantities relation in TypeORM's entity decorator. This sounds convenient — relations load automatically. What it actually means is: fetch 20 products, fire 100+ individual queries to load their variants. A page load was hammering the database unnecessarily.
The checkout had a race condition. Two users could both request the last unit of a product at the same moment. Both requests would read quantity = 1, both would pass the stock check, and both would decrement — leaving the stock at -1. Classic oversell.
Emails were blocking request threads. When a user registered, the API synchronously called Mailtrap, waited for the HTTP response, and only then replied to the user. Mailtrap latency was holding up the Node.js event loop.
There were only 3 database indexes on the products table. Foreign key columns across other tables were completely unindexed.
Phase 1: Getting the Foundation Right
The first phase was about fixing the structural things — the kind of problems that don't surface until you're under load and then surface everywhere at once.
Cluster Mode
PM2 has a cluster mode that uses Node's built-in cluster module. The primary process forks a worker for each CPU core, and all workers share the same TCP socket. Load is distributed across them automatically. Changing exec_mode: 'fork' to exec_mode: 'cluster' and instances: 1 to instances: 'max' was a two-line change that immediately multiplied throughput by the number of cores on the server.
This also forced a second change: rate limiting. In-memory counters break across workers (each worker has its own memory), so I moved the rate limiting store to Redis. The express-rate-limit package accepts a custom store, and rate-limit-redis provides exactly that. Now all workers share one counter per IP.
Database Pool and Timeouts
I bumped the connection pool from 10 to 25 and added three timeouts I should have had from the start:
-
statement_timeout: 10 seconds. If a query takes longer than this, kill it. A runaway query should not hold a connection hostage indefinitely. -
idle_in_transaction_session_timeout: 30 seconds. If a transaction sits open and idle (perhaps because the application crashed mid-transaction), PostgreSQL will close it. -
connectionTimeoutMillis: 5 seconds. If all connections are busy and a new one cannot be acquired within 5 seconds, fail fast rather than queuing indefinitely.
The N+1 Fix
This one required some reading. TypeORM's eager: true only fires for find*() methods — findOne, find, findAndCount. It is silently ignored when you use createQueryBuilder. Every listing endpoint was already using createQueryBuilder, which meant the eager relations were not loading at all, causing extra round trips when the code tried to access them.
The fix was to remove eager: true from the entity and add explicit leftJoinAndSelect calls in each query. Listing endpoints join only what they need (vendor and variants). The detail endpoint joins everything. Controlled, predictable, and significantly fewer queries.
17 Database Indexes
I wrote a migration that added 17 indexes using CREATE INDEX CONCURRENTLY IF NOT EXISTS. The CONCURRENTLY flag is important — it builds the index without locking the table, which matters in production. One thing worth knowing: TypeORM migrations run inside a transaction by default, and CONCURRENTLY cannot run inside a transaction. The migration class needs public readonly transaction = false to opt out.
The indexes cover: order lookups by user and status, cart items by user, transactions by user and status and date, reviews by product, ledger entries by wallet, and a partial index on products for browseable items (WHERE approved = true AND hidden = false).
Observability
Before adding more complexity, I wanted visibility. I integrated Sentry for error tracking and built a Prometheus metrics module that tracks request counts, response latencies (P50, P95, P99), active requests, and errors — broken down by endpoint and status code. The metrics are exposed at a protected /api/v2/metrics endpoint that Prometheus can scrape.
Sentry's NestJS integration has one requirement that is easy to miss: instrument.ts must be the very first import in main.ts, before NestJS, before Express, before anything else. It patches Node.js internals via OpenTelemetry and needs to run before any other module loads.
Phase 2: Race Conditions and Async Processing
With the foundation solid, phase two addressed the correctness problems under concurrent load.
BullMQ for Email
Emails should never block a request thread. The user signed up successfully — the database record exists. Whether Mailtrap's API responds in 100ms or 2 seconds is irrelevant to that fact. I introduced BullMQ (Redis-backed job queues) and moved all email sending to a background processor. The auth service now enqueues a job with the pre-built email payload and returns immediately. A worker processes the job asynchronously.
The setup is a global NestJS module that configures the Redis connection once and registers queues for email, notifications, and cart operations. Any module that needs to enqueue a job just injects the queue by name.
Fixing the Oversell Race Condition
The checkout flow needed a pessimistic lock. TypeORM's query builder supports setLock('pessimistic_write'), which translates to SELECT ... FOR UPDATE in PostgreSQL. The product row is locked for the duration of the transaction. Any concurrent request trying to check the same product's stock will block at the database until the first transaction commits. The second request then reads the updated quantity — which may now be zero — and throws a ConflictException cleanly.
Optimistic Locking on Order Status
Order status updates had a similar but different problem. Two concurrent calls to update the same order's status would both succeed, with the second silently overwriting the first. TypeORM handles this with @VersionColumn() — a numeric column it auto-increments on every save. If two requests read version N and both try to save, the second one's save will find the DB row at version N+1 and throw OptimisticLockVersionMismatchError. I wrap this in a transaction and surface it to the caller as a ConflictException.
Write-Behind Caching for Cart
This was the most architecturally interesting piece. The cart had an existing write-through cache: every write went to the database, then updated Redis. But the response still waited for both the database transaction and a full JOIN re-fetch before it returned. Functionally correct, but the user experienced database latency on every cart interaction.
The new pattern is write-behind:
-
Validate the product (one database read — unavoidable for correctness)
-
Read the current cart from Redis (cache-first; only hits the database on the first access per session)
-
Apply the mutation in memory — merge the new item, update quantity, whatever the operation requires
-
Write the updated cart object to Redis and return immediately
-
Enqueue a BullMQ job to persist the change to PostgreSQL asynchronously
The CartPersistenceProcessor handles the actual database writes — creating or upserting cart items, handling removals, clearing carts. It operates entirely in the background while the user's response has already been sent.
The result: adding an item to a cart now costs one database read (product validation) plus two Redis operations. Previous cost was three database roundtrips.
What Comes Next
Phases 3 and 4 are still ahead: PgBouncer as a connection pooler in front of PostgreSQL, Nginx load balancing when a second server comes online, Redis eviction policies, Grafana dashboards, and structured logging via Pino. Phase 5 includes a PostgreSQL read replica and CDN cache headers for product listing endpoints.
The broader lesson is not "how to scale Node.js" — there are a hundred articles about that. It is that most scaling problems are not exotic. They are a handful of configuration decisions made at the beginning of a project when load was not a concern, each one individually harmless, collectively capable of bringing a system to its knees. The work is less about clever architecture and more about going through the boring list and fixing each item.
One process became many. Ten connections became twenty-five with timeouts. Blocking calls became async jobs. Synchronous cache updates became cache-first writes. Each change was small. Together they represent the difference between a product that works for ten users and one that can handle a thousand.