What fails first when your simple API hits 10x traffic

Your app will probably fail at the edges before its algorithms fail. I take the unpopular position that the first scaling fix should usually be less code, more pressure, and…

Your app will probably fail at the edges before its algorithms fail. I take the unpopular position that the first scaling fix should usually be less code, more pressure, and stricter failure budgets. Junior developers are often told to “optimize the slow endpoint,” but the earlier breakage is usually connection waiting, retry storms, stale caches, and misleading averages.

The first thing to break is usually the waiting room, not the function body

The first visible slowdown in a growing web app is often queueing around shared dependencies, because each slow database query, HTTP call, or cache miss keeps a worker, socket, and memory allocation occupied while doing no useful work. That claim annoys people who like profiling individual functions, but it holds in many CRUD-heavy services because the expensive part is waiting on PostgreSQL, Redis, another API, or the network.

A junior developer may see CPU at 35 percent and assume the service is healthy, but CPU can stay low while p95 latency explodes because requests are parked in connection pools. Watch p95, p99, error rate, queue depth, active connections, DB lock wait time, and Apdex before celebrating a low average. A mean response time of 120 ms can hide a p99 of 2.8 seconds, and users experience the tail because real pages call several endpoints.

PostgreSQL 16 with pg_stat_statements will often show that a “fast” query is called too many times, not that it is individually slow. NGINX 1.25 can accept requests quickly while upstream workers are saturated, so its access logs look calmer than the app feels. Node.js 20 can also look idle while the app is waiting on remote I/O, because the event loop is not the same thing as downstream capacity.

A concrete threshold to tune is a p95 under 300 ms for an internal JSON endpoint, not because 300 is magic, but because it leaves budget for browser work, TLS, retries, and upstream hops. A different value may be right for your product, but a number must exist because “fast enough” cannot be tested by vibes.

Case Study: Boosting App Performance with Load Testing makes a useful argument for pressure testing, but I would be harsher about averages because average latency can improve while the worst 1 percent becomes unusable. I would not begin by rewriting business logic, because most early scaling failures are coordination failures between components rather than pure compute failures inside one method.

Your load test lies unless it creates backpressure like production

A load test with happy-path users and no think time is better than nothing, but it can still lie because it may miss the slow interactions that create backpressure. k6 v0.49.0, Locust 2.24, Apache JMeter 5.6, and wrk 4.2.0 can all generate traffic, but none of them automatically knows your login flow, cache-miss path, pagination size, or worst customer account.

This k6 script runs as-is after changing the URL, and it is intentionally small because the first useful test should be readable enough for a junior developer to challenge:

import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  vus: 50,
  duration: '2m',
  thresholds: { http_req_duration: ['p(95)<300'], http_req_failed: ['rate<0.01'] },
};

export default function () {
  const res = http.get('http://localhost:8080/api/products');
  check(res, { 'status is 200': r => r.status === 200 });
  sleep(1);
}

The 50 virtual users in that script are a starting value to tune, because a tiny team can reproduce saturation safely before spending money on a large distributed test. The 2-minute duration is only a smoke window, because connection leaks, JVM warmup behavior, and cache churn often need 20 to 60 minutes to become obvious. The 1 percent failure-rate threshold is deliberately strict for an API endpoint, because a browser page with ten API calls compounds small failure rates into a user-visible mess.

Do not run only a spike test, because sudden traffic proves admission behavior but does not expose slow memory growth or database bloat. Also do not run only a long soak test, because a gentle ramp may hide the retry storm that appears when a deploy sends fresh pods into cold caches. Use both, but keep the first version boring enough that the whole team can read it.

Use Prometheus 2.x and Grafana 10 to graph http_server_duration_seconds_bucket, process_resident_memory_bytes, nodejs_eventloop_lag_seconds, and pg_stat_activity counts while the test runs. Use OpenTelemetry 1.30 traces with W3C Trace Context headers because a single slow request may cross the app, database, Redis, and another HTTP service before anyone notices the real wait.

Caching is usually the second fix, because it can hide the first bug

I respect Case Study: Cutting API Latency 40 Percent with Caching, but I would not copy the cache-first move without a failing load test because caching can turn a capacity problem into a correctness problem. A cache hit is wonderful, but a cache miss at peak traffic can be worse than no cache if many requests stampede into the database at the same time.

The most common junior mistake is treating Redis as free speed, because Redis 7.2 is fast but still has network latency, memory limits, eviction policy, serialization cost, and failure modes. If maxmemory-policy allkeys-lru evicts a hot key during peak traffic, the app may suddenly push a burst of misses into PostgreSQL. If the app uses no TTL jitter, thousands of keys can expire in the same second and create a thundering herd.

A vendor-published default worth knowing is that Redis has maxmemory 0 by default, which means no configured memory cap, so the operating system may become the thing that enforces reality. A safer production stance is to set maxmemory, choose a policy such as volatile-ttl or allkeys-lru, and track evicted_keys, keyspace_hits, and keyspace_misses.

HTTP caching has different traps. Cache-Control from RFC 9111, ETag, If-None-Match, and stale-while-revalidate can remove repeated work before the request reaches the app, but they only help when responses are safe to reuse. A measured improvement such as “API latency fell by 40 percent” is believable for read-heavy data, because repeated reads are exactly what caching removes, but it says little about write-heavy endpoints where invalidation dominates.

Here is the explicit comparison I would make before adding a cache:

  • Redis application cache wins when data is user-specific, computed from several database queries, or reused across app instances; it costs extra operational care because you must handle TTLs, serialization, stampede protection, network partitions, and memory pressure.
  • CDN or HTTP edge cache, such as Cloudflare Cache Rules or Fastly VCL, wins when responses are public or safely varied by headers; it costs design discipline because wrong Vary, Authorization, or cookie handling can leak data or prevent caching entirely.

I would not cache a failing endpoint before measuring its query plan, because the cache may reduce symptoms while leaving an N+1 query, missing index, or lock contention ready to return during every cold start. Use EXPLAIN (ANALYZE, BUFFERS) in PostgreSQL 16 before adding Redis, because it tells you whether the database is scanning, sorting, locking, or waiting on I/O.

Retries, pools, and timeouts break before dashboards turn red

Retry behavior is one of the fastest ways to turn a small slowdown into an outage, because every retry adds extra work to a dependency that is already struggling. The dangerous version is not “three retries” in isolation; it is three retries from every app instance, through every endpoint, during the same failure window.

HTTP/2 from RFC 9113 helps multiplex requests over fewer TCP connections, but it does not remove the need for sane timeouts because a slow upstream can still hold streams and memory. TLS 1.3 from RFC 8446 reduces handshake overhead compared with older handshakes, but it does not save an app that opens fresh connections for every request because connection churn still burns CPU and latency budget.

Connection pools deserve earlier attention than most junior developers give them. HikariCP 5.x has settings such as maximumPoolSize, connectionTimeout, and leakDetectionThreshold, while node-postgres has max, idleTimeoutMillis, and connectionTimeoutMillis. A pool of 10 database connections per pod may be reasonable as a tunable starting point, but it becomes dangerous at 40 pods because PostgreSQL suddenly sees 400 possible client connections.

Kubernetes HPA using autoscaling/v2 can make this worse if it scales pods based only on CPU, because the app may be I/O-bound while CPU stays low and database pressure rises with every new replica. I like HPA for stateless services when paired with dependency metrics, but I distrust CPU-only autoscaling because it treats the symptom it can see as the problem it must solve.

Timeouts should be shorter than the user’s patience and shorter than the upstream’s meltdown window. For example, an internal service call with a 700 ms timeout can be a tunable budget if the page has several parallel calls, because waiting 5 seconds on one dependency wastes capacity and invites users to retry manually. Circuit breakers in Resilience4j 2.x, Envoy 1.29 outlier detection, or Linkerd 2.15 retries can help, but they must be configured from observed latency because generic retry policies are usually too optimistic.

Measure saturation directly. In Prometheus, look at rate(http_requests_total[5m]), histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])), and database wait metrics beside deployment markers. In Grafana, put p95 and error rate on the same panel because a latency-only chart can look acceptable while users receive fast failures.

The safest scaling plan deletes assumptions before it adds infrastructure

The plan I would give a junior developer is simple but uncomfortable: make the app fail on purpose in a controlled environment before arguing about architecture. This position is easy to disagree with because adding Redis, increasing pods, or buying a bigger database feels more productive, but those moves often preserve the unknown failure mode and increase the blast radius.

Start with a realistic request mix. Include login, one cacheable read, one uncached read, one write, and one endpoint that hits a third-party service or internal dependency. Keep the data volume honest: a local measurement against 1,000 rows tells you almost nothing if production pages filter across 8 million rows, because indexes and memory behavior change once the working set no longer fits comfortably in cache.

Then force one dependency to become slow. Add 500 ms delay to a mock service, reduce the database pool, or temporarily lower Redis memory in a staging environment. This is not chaos theater, because the goal is narrow: learn whether requests queue, retry, fail fast, or corrupt user-visible behavior. Toxiproxy 2.9 is useful here because it can add latency and packet loss between your app and a dependency without rewriting the app.

After that, fix the first bottleneck with the least magical change. Add a missing PostgreSQL index if EXPLAIN proves a scan. Add request coalescing if cache misses stampede. Add a timeout if workers pile up behind a slow service. Add a CDN rule if the response is public and correctly controlled by Cache-Control. Add more pods only after dependency capacity is understood, because horizontal scaling a wasteful service can multiply waste.

I would not begin with a “scalable architecture” rewrite, because a rewrite delays feedback and can reproduce the same pool, timeout, and cache errors in a more complicated shape. I would also not trust a single benchmark number, because performance changes with data shape, concurrency, network distance, and failure behavior.

Your first concrete step is to write one load script for the most-used read endpoint, run it until p95 or errors fail a threshold, and save the graph beside the code review. Then add one production-like dependency metric to the same dashboard. Scaling gets less mysterious once every proposed fix has to move a visible number.