System Design is not a contest to put fashionable technologies on a whiteboard. First identify what the product must do, how much traffic and data it serves, and what latency or loss it can tolerate. Then draw the request path, locate state and bottlenecks, and only then choose a load balancer, cache, database, queue, or additional replicas.
Understanding: System Design foundations
You can read this chapter before running the experiment. Then return to the live trace and match each concept to a real event.
System design connects functional requirements to quality attributes: latency, availability, durability, consistency, security, and cost. Numerical estimates establish an order of magnitude, an SLI measures observed behavior, an SLO sets a target, and architecture decisions redistribute specific risks. Horizontal scaling helps a stateless tier; state, hot keys, and downstream limits need separate treatment.
Terms used in this experiment
Understand the words first, then the execution order.
System Design
The process of designing system components, data, and interactions against explicit requirements, constraints, and failure modes.
Functional requirement
A user capability or business rule, such as uploading a document, placing an order, or finding a publication.
Quality attribute
A measurable property such as latency, availability, durability, consistency, security, or cost.
SLI / SLO / SLA
An SLI is a measurement, an SLO is an internal target for it, and an SLA is a contractual promise with consequences.
Throughput and concurrency
Throughput is completed work per unit of time; concurrency is work simultaneously present in the system.
Load balancer
A component that distributes traffic across healthy replicas; it does not scale a shared database or shared state.
Horizontal scaling
Adding service instances. It is easier for stateless APIs and harder for stateful components and background work.
Backpressure
A mechanism that prevents a producer from feeding work indefinitely faster than a consumer can process it.
Replication and partitioning
Replication creates copies for availability or reads; partitioning divides a data set across nodes.
RPO / RTO
RPO bounds acceptable data loss in time, while RTO bounds acceptable service recovery time.
Idempotency
Repeating an operation with the same key does not create a second business effect, which matters after timeouts and retries.
What happens step by step
Each step maps to an observable runtime state.
- 01Fix the scope and primary use cases
Name actors, reads, writes, data, and the capabilities deliberately excluded from this design.
- 02State measurable objectives
For example, 99% of reads below 300 ms over 28 days, under 0.1% errors, and an RPO of five minutes.
- 03Estimate the order of magnitude
Use DAU, actions per user, peak factor, and payload size to estimate average and peak RPS, storage, and bandwidth.
- 04Draw the request and data flow
Show the client, edge or load balancer, API, cache, primary database, replicas, queue, workers, and external dependencies.
- 05Assign ownership of state
For every record, decide its source of truth, access key, consistency boundary, lifecycle, and recovery method.
- 06Walk through failure modes
Consider timeout, duplicate, partial failure, overload, stale cache, replica lag, zone loss, and poison messages.
- 07Prove the design with measurements
Use load tests, p95 and p99, saturation, queue depth, error rate, fault injection, and restore drills.
Where the result needs context
These details explain why similar code can sometimes produce a different trace.
An average hides the peak
Daily average RPS can look small while a campaign creates a tenfold burst within one minute.
Caching changes correctness
It reduces load and latency but introduces TTL, invalidation, stampede, and stale responses.
Retries multiply overload
Without a deadline, exponential backoff, and jitter, retries amplify an outage into a retry storm.
A queue does not create capacity
It absorbs a short burst; if input remains above processing rate, backlog and waiting time grow without bound.
A replica may lag
Read replicas scale reads, but read-after-write may require the primary or a session-consistency mechanism.
Multi-region is not a free checkbox
It adds network latency, conflict handling, failover complexity, cost, and a need for regular recovery testing.
First understand which parts of Node participate in execution.
Then remove instrumentation and focus on the central mechanism.
Finally match the model to the code that produces the live trace.
A minimal model without instrumentation
const requestsPerDay = dau * actionsPerUser;
const averageRps = requestsPerDay / 86_400;
const peakRps = averageRps * peakFactor;
const replicas = Math.ceil(
peakRps / (rpsPerReplica * targetUtilization),
);The complete code executed by the scenario
This is not an alternative example: these are the functions and files used by the Run button.
The source is generated from the real server function. Scenarios using a child process or Worker include every participating file.
const SECONDS_PER_DAY = 86_400;
const BITS_PER_BYTE = 8;
export function estimateCapacity({
dailyActiveUsers,
actionsPerUser,
peakFactor,
averagePayloadKb,
rpsPerReplica,
targetUtilization,
}) {
const requestsPerDay = dailyActiveUsers * actionsPerUser;
const averageRps = requestsPerDay / SECONDS_PER_DAY;
const peakRps = averageRps * peakFactor;
const peakMegabitsPerSecond =
(peakRps * averagePayloadKb * 1024 * BITS_PER_BYTE) / 1_000_000;
const usableRpsPerReplica = rpsPerReplica * targetUtilization;
const servingReplicas = Math.max(
1,
Math.ceil(peakRps / usableRpsPerReplica),
);
return {
requestsPerDay: Math.round(requestsPerDay),
averageRps: Number(averageRps.toFixed(1)),
peakRps: Number(peakRps.toFixed(1)),
peakMegabitsPerSecond: Number(peakMegabitsPerSecond.toFixed(1)),
servingReplicas,
replicasWithOneFailure: servingReplicas + 1,
};
}
export async function systemDesignCapacity(emit) {
const assumptions = {
dailyActiveUsers: 120_000,
actionsPerUser: 35,
peakFactor: 8,
averagePayloadKb: 6,
rpsPerReplica: 250,
targetUtilization: 0.6,
};
emit(
'requirements',
'input',
'Цель: выдержать пользовательский пик и потерю одной API replica',
);
emit(
'capacity',
'assumption',
`Предположения: DAU=${assumptions.dailyActiveUsers}, действий/пользователь=${assumptions.actionsPerUser}, peak factor=${assumptions.peakFactor}`,
);
const estimate = estimateCapacity(assumptions);
emit(
'capacity',
'result',
`Среднее=${estimate.averageRps} RPS; проектный пик=${estimate.peakRps} RPS; egress≈${estimate.peakMegabitsPerSecond} Mbit/s`,
);
emit(
'api-tier',
'headroom',
`Нужно ${estimate.servingReplicas} serving replicas при 60% target utilization; N+1=${estimate.replicasWithOneFailure}`,
);
emit(
'database',
'constraint',
'Расчёт API не доказывает capacity базы, connection pool, cache или downstream',
);
emit(
'validation',
'next-step',
'Следующий шаг: load test с реальным traffic mix и проверкой p95/p99, ошибок и saturation',
);
return estimate;
}
The application instruments its own live trace: rows and timestamps are recorded by real emit(...) calls, while the scenario supplies source and lane labels. This is not a V8/libuv profiler or a direct view of their internal queues. await and Promise keep the HTTP stream open until the scenario completes.
Practical patterns worth keeping nearby
Compare the goal, code, and caveats instead of memorizing syntax without a model.
Rough capacity budget
Estimate peak RPS before selecting a replica count.
const requestsPerDay = dau * actionsPerUser;
const averageRps = requestsPerDay / 86_400;
const peakRps = averageRps * peakFactor;
const replicas = Math.ceil(
peakRps / (rpsPerReplica * targetUtilization),
);- Label every input as an observed fact or an assumption.
- Validate p95, p99, and errors, not just RPS.
- The database, connection pool, or downstream may saturate before API CPU.
Idempotent write
Survive a repeated POST after a client timeout.
INSERT INTO payments (idempotency_key, order_id, amount)
VALUES ($1, $2, $3)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id, status;- The idempotency key needs a UNIQUE constraint.
- A duplicate should return the previous outcome.
- Define the key scope and retention period.
Deadline and bounded retry
Avoid waiting forever or creating a retry storm.
const response = await fetch(url, {
signal: AbortSignal.timeout(800),
});
if (response.status >= 500 && attempt < 2) {
await sleep(backoffWithJitter(attempt));
}- Not every operation is safe to retry.
- An overall deadline matters more than unrelated timeouts.
- 429 and Retry-After require an explicit policy.
Bounded queue consumer
Protect the database from unbounded worker concurrency.
new Worker('previews', renderPreview, {
connection,
concurrency: 8,
limiter: { max: 40, duration: 1_000 },
});- Concurrency limits one worker process.
- The rate limit must account for every replica sharing the downstream.
- Alert on queue depth and age of the oldest job.
Readiness is not liveness
Remove an unready Pod from traffic without needless restarts.
@Get('/ready')
ready() {
return this.dependencies.canServeTraffic()
? { status: 'ready' }
: (() => { throw new ServiceUnavailableException(); })();
}- Readiness asks whether the instance should receive new traffic.
- Liveness should detect a state that a restart can repair.
- Deep checks of every dependency can create load themselves.
How a learning mistake becomes an incident
A realistic service: the original code, observable failure, corrected implementation, and why the correction works.
Report export ran inside the HTTP request
A Nest endpoint builds a heavy report and waits for external storage, so a spike holds every HTTP connection open.
Latency reaches the timeout, clients repeat the POST, the same export runs more than once, and the database pool is exhausted.
@Post(':id/export')
async export(@Param('id') id: string) {
const rows = await this.reports.loadAllRows(id);
const file = await this.renderer.render(rows);
return this.storage.upload(file);
}A long CPU and I/O chain is tied to the request lifetime and lacks an idempotency key, bounded concurrency, and a separate capacity budget.
@Post(':id/export')
@HttpCode(202)
async export(
@Param('id') id: string,
@Headers('idempotency-key') key: string,
) {
const job = await this.exports.enqueueOnce({ reportId: id, key });
return { jobId: job.id, statusUrl: '/exports/' + job.id };
}
@Processor('exports')
export class ExportWorker extends WorkerHost {
async process(job: Job<ExportJob>) {
return this.exports.buildAndStore(job.data);
}
}HTTP acknowledges quickly, a durable queue buffers the spike, a unique key deduplicates retries, and worker concurrency protects the database and storage.
What the unfamiliar calls from both code samples actually do.
@HttpCode(202)- States that work was accepted but is not complete; the client needs a separate URL or event to observe the outcome.
@Headers()- Reads the idempotency key from an HTTP header; the server must validate its format, scope, and user ownership.
enqueueOnce()- An application operation that atomically creates a job or returns the existing outcome for the same key.
WorkerHost.process()- The Nest BullMQ job handler whose concurrency, timeout, retries, and cleanup are configured outside the controller.
A read replica was added without a consistency rule
Immediately after creating an article, the UI fetches it, while random read balancing may select a lagging replica.
The user intermittently receives 404 for a newly created object even though the write committed successfully on the primary.
@Get(':id')
findOne(@Param('id') id: string) {
return this.randomReplica.query(
'SELECT * FROM articles WHERE id = $1',
[id],
);
}The replica is selected without considering replication lag or the user-facing read-after-write expectation.
@Get(':id')
findOne(
@Param('id') id: string,
@Headers('x-read-token') token?: string,
) {
const db = token && this.readTokens.isFresh(token)
? this.primary
: this.replica;
return db.query(
'SELECT * FROM articles WHERE id = $1',
[id],
);
}A short-lived token routes related post-write reads to the primary, while ordinary scalable reads stay on a replica.
What the unfamiliar calls from both code samples actually do.
x-read-token- A short-lived opaque marker connecting a read to a recent user write without exposing a database position.
isFresh()- Validates signature, scope, and expiry; this is a consistency-routing policy, not synchronous replication.
this.primary- A connection pool to the node that accepted the write and therefore sees the commit before asynchronous replicas.
SQL $1- A positional SQL parameter that separates the user value from query text and prevents SQL injection.
Common misconceptions
The myth is on the left; the accurate model is on the right.
System Design means drawing more boxes.
A diagram is useful only with requirements, numbers, state ownership, failure modes, and testable trade-offs.
A load balancer makes the whole system scalable.
It distributes traffic, but a shared database, lock, hot key, or external API may remain the bottleneck.
Two replicas guarantee availability.
The same bad deployment, shared zone, corrupt data, or broken failover can remove both copies.
A backup means the data is safe.
You need measurable RPO and RTO, independent storage, integrity checks, and rehearsed restores.
Autoscaling fixes every overload.
Scaling arrives late and may overload the database faster without admission control and backpressure.
Explain it in your own words
If you can explain the answer without quoting documentation, your mental model is starting to take shape.
- Which functional and non-functional requirements do you ask for before drawing an architecture?
- How can DAU produce a rough peak RPS, and why is that still insufficient?
- How do SLI, SLO, and SLA differ?
- Why can a queue smooth a burst but not fix permanently insufficient capacity?
- Where does the system need an idempotency key, and what is its scope?
- When is a stale read acceptable, and when must a read use the primary?
- How do you prove that a backup meets its RPO and RTO?
- Which signals reveal a bottleneck before user-facing timeouts?