Backend System Design by Bottleneck: From URL Shortener to E-Commerce
A requirements-first way to design Node.js backends: when to add load balancers, Redis, replicas, queues and rate limits, and what each one costs you.
This article is published in English.
Sources: System Design for Backend Engineers: A Practical Guide With Real Examples
Many engineers approach system design by assembling a diagram: a load balancer, Redis, Kafka, an API gateway, a dozen microservices. The result looks impressive and explains nothing. Whether in an interview or on a real product, design is the practice of choosing the right components for a specific problem and being able to justify each one. This guide walks through that reasoning with two concrete systems, a URL shortener and a small e-commerce backend, so you can grow an architecture one justified step at a time and name the trade-off that every step introduces.
Start from the requirements, not the technology
The most common early mistake is to start with tools. Someone describes a problem and the first thoughts are "Redis or not? Kafka? Microservices?" That order is backwards. Until you know what the system must do and at what scale, you have no basis for choosing anything.
Take a URL shortener. Before sketching a single box, you need answers to questions like these:
- How many users will it have, and how many URLs will they create?
- What request rate should it handle at peak?
- How long do links stay valid, and can they expire?
- Do users need click analytics?
- How much downtime is acceptable?
- How fast must a redirect be?
Each answer moves the design. A service that sees this much traffic:
1,000 requests/day
has almost nothing in common with one that must absorb this:
10 million requests/secon
The first fits comfortably on a single small server. The second forces decisions about caching, replication and geography from day one.
Begin with the simplest architecture that works
For a small URL shortener, the first version can be almost trivially simple:
Client
↓
Node.js API
↓
PostgreSQL
The API generates a short code and returns a link such as:
https://example.com/abc123
It then stores a single mapping in PostgreSQL:
short_code → original_url
When a browser requests a path like the one below, the API looks up the original URL and responds with a redirect:
/abc123
For a modest workload, that is a complete, correct system. Nothing about it demands the usual suspects:
Kafka
Redis
Kubernetes
Microservices
Adding them because they are available only buys operational cost, more failure modes and more to learn for every new team member. Ship something that works, measure it, and then scale the parts that turn out to be bottlenecks.
Scale out when one server is not enough
Suppose the shortener becomes popular and its lone API process is overwhelmed:
1 API server
You could keep buying a bigger machine, but vertical scaling has a ceiling and a single machine is a single point of failure. The more common move is to run several identical instances behind a load balancer:
Load Balancer
↓
┌───────────┼───────────┐
↓ ↓ ↓
Node API Node API Node API
↓ ↓ ↓
└───────────┼───────────┘
↓
PostgreSQL
Requests are now spread across multiple application instances. This is horizontal scaling: rather than relying on
1 huge server
you rely on
multiple smaller servers
Horizontal scaling only works if the instances are interchangeable, and that raises questions that did not exist before:
- Where does session state live, if a user's next request may hit a different instance?
- Is every instance truly stateless?
- Where do uploaded files go, since local disk is no longer shared?
- How does each instance reach shared infrastructure such as the database?
Scaling one component tends to create requirements somewhere else; here, state has to move into shared stores.
Add a cache when you actually have a caching problem
Popular short links get requested over and over:
/abc123
/abc123
/abc123
/abc123
/abc123
There is no reason for each of those requests to query PostgreSQL. A cache in front of the database serves hot keys from memory, falling back to the database on a miss and storing the result for next time:
Client
↓
Load Balancer
↓
Node.js API
↓
Redis
│
├── Cache Hit → Return URL
│
└── Cache Miss
↓
PostgreSQL
↓
Redis
This cache-aside flow can remove most read load from the database for a skewed workload like link redirects. It also introduces a new question: if a short link's target is changed or deleted, how does the cached copy find out? You now need an invalidation strategy, whether that is deleting the key on every write, setting a time-to-live, or both. For a deeper look at those choices, see our guide to Redis caching patterns and pitfalls.
Every improvement in this guide comes with a cost like this one. Being able to name that cost is what separates a design from a diagram.
Fix the database before adding more databases
At some point PostgreSQL may look like the bottleneck. Before adding new infrastructure, find out why it is slow. Common causes include:
- Missing indexes on columns used for lookups
- Inefficient queries or joins
- N+1 query patterns in the application
- Returning far more rows or columns than needed
- Poor pagination, such as large offsets
- A misconfigured or exhausted connection pool
Quite often the fix is a single statement:
CREATE INDEX ...
That is cheaper and safer than a new component. The rule to keep in mind: fix the bottleneck you have measured before adding architecture around it.
Read replicas for read-heavy workloads
Many applications read far more than they write, for example:
90% reads
10% writes
In that situation the primary database can drown in read traffic. One remedy is to add read replicas: the primary accepts all writes, and replicas take a share of the reads.
Node.js API
↓
┌────────┴────────┐
↓ ↓
Primary DB Read Replica
↓ ↑
Writes Reads
The cost of replication lag
Replicas are updated asynchronously in most setups, so a write lands on the primary before it reaches a replica:
Write → Primary
↓
Replication
↓
Read Replica
If a request writes something and then immediately reads it back from a replica, it may see stale data for a short window. The application therefore has to know which reads need the latest state. A common approach is to send "read your own writes" traffic, such as loading a profile right after saving it, to the primary, and let everything else go to replicas.
Move slow work into background jobs
Many operations do not need to finish before the user gets a response:
- Sending emails
- Generating invoices
- Delivering notifications
- Building reports
- Calling slow external services
Instead of making the user wait, the API records what needs to happen, enqueues a job and responds right away:
Client
↓
Node.js API
↓
Create Record
↓
Queue Job
↓
Response
A separate worker process picks up the job and does the work:
Queue
↓
Worker
↓
Process Job
In practice one queue can feed workers that handle several job types:
API
↓
Redis / Queue
↓
Worker
├── Email
├── Invoice
└── Notification
This keeps request latency low and gives you a controlled place for retries and throughput limits. It also brings its own set of problems to design for:
- A job can run more than once, so handlers must tolerate duplicates.
- Aggressive retries against a failing dependency can turn into a retry storm.
- Failed jobs need a place to go and someone to look at them.
- Some workflows require ordering guarantees.
- Handlers should be idempotent.
- Queue depth and failure rates need monitoring.
Scalability is never free. If you are building this in Node.js, our walkthrough of background job systems with BullMQ and Redis covers these issues in code.
Design for failure before it happens
Interesting design work starts when you stop asking only what happens when everything works and start asking what happens when a piece breaks.
Degrade gracefully when a dependency goes down
Suppose Redis becomes unavailable. Should the whole API stop? If Redis is used purely as a cache, it probably should not. The API can skip the cache and read from PostgreSQL, slower but still correct:
Request
↓
Redis
↓
FAIL
↓
PostgreSQL
↓
Response
If Redis also backs your job queue or rate limiter, an outage is far more serious, because those responsibilities have no simple fallback. The design should make each component's role explicit, since that role determines how the system behaves when the component fails.
Put a timeout on every external call
Imagine the API calls an external payment service with no timeout configured:
API
↓
Payment Service
↓
... waiting ...
↓
... waiting ...
↓
... waiting ...
While it waits, the server keeps sockets, memory and request slots tied up for a dependency that may never answer. Under load, those stuck requests can exhaust the whole process. A well-behaved system gives each call a deadline:
API
↓
Payment Service
↓
5 second timeout
↓
Failure
Once the deadline passes, the application has to decide how to proceed, and the right choice depends on the operation:
- Retry the call
- Return an error to the client
- Queue the operation for later
- Use a fallback
- Mark the operation as pending and reconcile afterwards
Retries are only safe when operations are idempotent
After a payment call times out, retrying seems obvious. But a timeout does not tell you whether the payment failed. The charge may have succeeded and only the response was lost. A blind retry could bill the customer twice.
The fix is idempotency: attach a stable identifier to the operation so the receiving side can recognise a repeat.
payment_id = order_123
The payment provider uses that key to detect that the request was already handled and returns the original result instead of charging again:
Request
↓
Idempotency Key
↓
Payment Provider
↓
Already processed?
├── Yes → Return previous result
└── No → Process payment
Adding retries takes one line of code. Making them safe requires the whole path to cooperate. Our article on idempotency keys in Node.js POST endpoints shows how to implement the receiving side yourself.
Protect the system with rate limiting
Now suppose a single client starts sending this much traffic:
100,000 requests/minute
Accepting all of it would let one caller degrade the service for everyone. A rate limiter sits in front of the API and rejects excess requests with HTTP 429:
Client
↓
Rate Limiter
↓
Allowed?
├── Yes → API
└── No → 429
With several API instances, the counters must be shared, which is why Redis is a popular backing store for distributed rate limiting: every instance increments the same keys.
Limits should reflect what each endpoint does. A login endpoint is a brute-force target, so a strict limit such as this one is reasonable:
Login:
10 requests/minute
A public product listing, by contrast, can tolerate much more:
GET /products:
1000 requests/minute
Set limits from the operation's cost and its threat model rather than applying one global number.
System design is mostly trade-offs
There is rarely a single perfect architecture. Most decisions are choices between options that are each good at different things.
SQL versus NoSQL
A relational database offers strong relational modelling, transactions and expressive queries. A NoSQL store can offer different scaling characteristics, flexible schemas and advantages for particular workloads. The shape of your data and your access patterns decide which fits.
Cache versus database
A cache is fast but temporary, and it adds complexity around invalidation. The database is durable and remains the source of truth, though it may be slower for data that is read constantly.
Monolith versus microservices
A monolith is simpler, easier to deploy and easier to debug in the early stages. Microservices provide independent deployment and scaling and firmer boundaries between teams, at the price of much more distributed-systems complexity.
No option is correct in general. The requirements decide.
Prefer a modular monolith to early microservices
This choice deserves its own section, because it is where over-engineering is most expensive. A new product does not need to begin as a set of services like these:
Auth Service
User Service
Product Service
Order Service
Payment Service
Notification Service
A better default is a single deployable application:
Node.js Application
organised internally into modules with clear boundaries:
├── Auth
├── Users
├── Products
├── Orders
├── Payments
└── Notifications
You get one codebase, one deployment and explicit separation between domains. If a module later needs to scale or deploy independently, the clean boundary makes it straightforward to extract.
Microservices do solve real organisational and scaling problems. They also bring network failures between services, distributed tracing, service discovery, heavier deployment pipelines, data consistency challenges across service boundaries, and a lot more monitoring and infrastructure. Take on those costs only when you need the benefits.
Solve each bottleneck at the smallest level that works
Consider a plain three-tier system with a slow database:
Client
↓
API
↓
Database
There is a ladder of possible responses, from cheapest to most disruptive. If a specific query is slow, optimise it:
Slow query
↓
Optimize query
If lookups scan the whole table, add an index:
Missing index
↓
Add index
If the same data is fetched repeatedly, add a cache:
Repeated reads
↓
Add cache
If reads dominate overall, add replicas:
Read-heavy workload
↓
Read replicas
Only at genuinely large scale do you reach for structural changes:
Massive scale
↓
Partitioning / sharding / architectural changes
The order is the point. Each step is taken only when the previous one no longer suffices, and each adds more complexity than the one before.
A repeatable process for design interviews
The same reasoning maps onto a system design interview. A solid sequence is:
- Clarify the functional and non-functional requirements.
- Estimate traffic and storage.
- Propose the simplest architecture that meets them.
- Identify where it will break first.
- Scale those bottlenecks specifically.
- Walk through failure scenarios.
- Discuss the trade-offs you are making.
- Justify every component you added.
That approach is far more convincing than immediately drawing twenty boxes. A polished diagram without reasoning behind it is not a good design.
Putting it together: a small e-commerce backend
Applying these ideas to an online store gives a reasonable starting architecture like this:
Client
↓
Load Balancer
↓
┌────────┴────────┐
↓ ↓
Node API Node API
│ │
└────────┬────────┘
↓
┌────────┼────────┐
↓ ↓ ↓
Redis PostgreSQL Queue
│ ↓
│ Worker
│ ↓
│ Email / Invoice
│
↓
External APIs
Payment / Shipp
What makes it defensible is that every component answers a question:
- Redis handles caching, short-lived data, rate-limit counters and queue infrastructure.
- PostgreSQL stores durable business data and provides relational transactions for orders and payments.
- The queue absorbs slow, asynchronous work.
- Multiple API instances provide horizontal scaling and redundancy.
- The load balancer distributes traffic across those instances.
- Workers process background jobs such as emails and invoices independently of API requests.
- External APIs for payment and shipping are called with timeouts and idempotency keys.
Notice that Redis carries several roles here. That is efficient, but it also means a Redis outage affects caching, rate limiting and job processing at once, which is exactly the kind of dependency the failure analysis above should surface.
Six questions to ask before finalising a design
- What problem does this solve? If you cannot state it clearly, the architecture is not ready.
- What scale is required? Users, request rate, data volume, storage and latency targets.
- What happens when each part fails? The database, Redis, an API instance, the network, an external provider.
- What must be fast? Reads, writes, search or real-time communication.
- What can be asynchronous? Emails, reports, notifications, analytics.
- Which trade-off am I accepting? Every significant decision should come with a reason.
Appropriate complexity beats impressive complexity
The biggest trap in learning system design is the belief that a more complicated architecture must be a better one. It is not; it is only more complicated. Good design aims for appropriate complexity.
If one Node.js application and a PostgreSQL database meet the requirements, that is a perfectly good architecture. When traffic grows, add instances. When reads become the bottleneck, optimise them. When caching helps, add Redis. When work can happen later, add a queue. When a module truly needs independent scaling or deployment, consider extracting it. Architecture is something that evolves with the product.
Key takeaways
- Requirements and rough capacity estimates come first; technology choices follow from them.
- Start with the simplest working system and scale only what measurements show is a bottleneck.
- Every addition has a price: shared state for horizontal scaling, invalidation for caches, lag for replicas, duplicates for queues.
- Plan for failure explicitly with fallbacks, timeouts, idempotent retries and rate limits.
- A modular monolith is a strong default; microservices are a response to specific needs, not a starting point.
- The goal is the simplest system that reliably meets today's requirements and can evolve as they change.