Performance work has a cruel irony at its center: the tools you reach for to make an app faster tend to make it slower over time. Not because engineers are bad at their jobs, but because each optimization is also a feature, and features compound. Here’s the specific machinery by which this happens.

1. Caching Adds a Second System That Also Needs to Be Fast

Caching is the first thing engineers reach for when an app feels slow, and it works. Fetch something expensive once, store it, serve it from memory on subsequent requests. Latency drops visibly. Everyone feels good.

Then the cache needs invalidation logic. Then the invalidation logic has bugs, so you add monitoring. The cache needs warming on startup, or cold traffic spikes kill your origin. You add a cache warming job. The cache layer itself becomes a latency contributor under certain conditions, so you add a local in-process cache in front of the distributed cache. Now you have two caches with different consistency guarantees and a class of bugs that only appears in production under specific timing conditions. The app is measurably faster for most users and measurably more complex for all engineers.

Redis is a good product. But every Redis deployment I’ve seen in a production codebase older than two years has grown a surrounding ecosystem of helper scripts, fallback logic, and informal rules that nobody fully remembers.

2. Lazy Loading Creates a New Category of Latency

Lazy loading is correct. Loading everything upfront is wasteful and makes initial page load feel sluggish. So you split your JavaScript bundle, defer non-critical images, and load components on demand. Your Lighthouse score improves. Your product manager screenshots the improvement and shares it in Slack.

The problem arrives when the user actually navigates. That lazy-loaded component now has to be fetched, parsed, and executed at the exact moment the user wants it. If the network is slow or the device is a mid-range Android phone, the user experiences a janky pop-in or a spinner where content should be. You’ve traded one kind of slowness for another, more visible kind.

The fix is predictive prefetching: guess what the user will do next and load it before they do it. This requires instrumentation, heuristics, and often a small backend service to serve personalized prefetch hints. The bundle is lazy. The infrastructure required to make lazy loading feel fast is not.

Chart showing feature performance improving while system complexity grows faster in the opposite direction
The optimization paradox: each individual feature gets faster while the system that contains it gets harder to reason about.

3. CDNs Distribute Your Content and Also Distribute Your Problems

Pushing static assets to a CDN is one of the highest-leverage things you can do for a globally distributed user base. Latency is dominated by geography, and putting files closer to users works. This is not controversial.

But a CDN is a third-party system with its own caching semantics, its own propagation delays, and its own failure modes. When you’re debugging why users in Singapore are seeing stale CSS, you’re now debugging a system you don’t operate. Cache-busting strategies, origin shield configuration, edge logic rules, and purge APIs become part of your deployment process. Many teams end up with CDN configuration drift: what’s documented and what’s actually configured diverge over time, and nobody notices until a deploy goes wrong.

The CDN made the app faster. The CDN also added a new surface area where things can go wrong in ways that look, to the user, exactly like slowness.

4. Async Everything Means You’re Always Waiting for Something

Asynchronous processing is obviously correct for expensive operations. Don’t make the user wait for an email to send. Don’t block a web request on a database write that can be queued. Offload to a worker, return immediately, show a spinner or optimistic UI.

Now you have a queue. The queue needs a consumer. The consumer can fall behind. When it falls behind, users get delayed feedback on actions they thought were instant. You add monitoring on queue depth and consumer lag. You add dead-letter queues for failed jobs. You add retry logic with exponential backoff. You add alerting when the dead-letter queue grows.

The original request path is fast. The system as a whole has more moving parts than before, each of which can slow down independently, and the failure modes are harder to reason about because they’re no longer synchronous. As discussed in the microservices context, distributed systems don’t remove complexity, they relocate it.

5. Database Indexes Trade Write Speed for Read Speed, and You Need Both

An index on a heavily queried column can drop a query from seconds to milliseconds. This is well understood and the improvement is real. Teams add indexes whenever they find a slow query, which is the right instinct.

The problem is that indexes have a cost on writes. Every insert, update, or delete has to maintain every index on that table. Add enough indexes and your write performance degrades. Add the wrong index and the query planner may use it even when a full scan would be faster. Many applications that feel fast on reads and slow on writes have an index accumulation problem: years of engineers adding indexes to fix slow read queries, with nobody auditing the total cost on the write path.

Indexes are not free. They are a durable trade-off embedded in your schema, and unlike code, they don’t come with comments explaining why they were added.

6. Prefetching Requires Predicting the Future, and You’re Bad at It

Prefetching is the logical endpoint of performance optimization: don’t wait for the user to ask for something, load it before they need it. Link prefetching, DNS prefetching, speculative execution. When it works, the app feels instant.

When it doesn’t work, you’ve wasted bandwidth loading things the user never wanted, potentially triggered side effects on resources that weren’t idempotent, and added load to your backend that doesn’t correspond to real user demand. Prefetch the wrong things and you can actually make the real requests slower by saturating the user’s connection or your server’s capacity.

Building a good prefetching system means building a prediction model, which means instrumentation, training data, and a feedback loop. The feature that was supposed to make the app feel instant now requires a small machine learning pipeline to work correctly.

The Pattern

Each item on this list follows the same structure. An optimization is real and valid. It solves a specific, measurable problem. It also introduces new infrastructure, new failure modes, or new complexity that future engineers inherit. The app gets faster on the dimension that was measured. The system as a whole gets heavier.

The solution isn’t to avoid optimization. It’s to count the total cost honestly, including the maintenance burden, the new dependencies, and the staff hours required to understand the system six months from now. Deleting a feature is harder than building one, and the same is true of performance infrastructure: once it’s in place, it’s very hard to remove, even if the original bottleneck no longer exists.