Design for the Cloud — A Practical Series

Performance Antipatterns

Article 3 of 5 · The Traps

Jun 10, 2026 · devops · 32 min read · 6200 words intermediate

Cloud performance antipatterns, explained simply.

devops cloud architecture performance antipatterns

An antipattern is a common "solution" that looks reasonable but quietly makes things worse — especially under load. This is Part 3 of the Design for the Cloud series, and it walks through the ten classic cloud performance antipatterns in plain language: Busy Database, Busy Front End, Chatty I/O, Extraneous Fetching, Improper Instantiation, Monolithic Persistence, No Caching, Noisy Neighbor, Retry Storm, Synchronous I/O — plus a few more you'll meet in the wild. For each one: what it is, how to spot it, why it happens, and exactly how to fix it. No jargon for its own sake.

Here's the thing nobody tells you when you move an app to the cloud: the code that ran fine on one beefy server can fall apart when it's spread across a network, billed per request, and hit by a thousand users at once. The cloud doesn't forgive lazy I/O, chatty calls, or "just throw it in the database" the way a single machine did. The mistakes that used to cost you a few milliseconds now cost you money, latency, and 3am pages.

These mistakes are common enough that they have names. We call them antipatterns — patterns, but the bad kind. An antipattern isn't a one-off bug. It's a design choice that seems sensible (or just convenient) and works in testing, then bites you the moment real traffic shows up. Learning to recognize them is one of the highest-leverage skills in cloud engineering, because the fixes are usually well-understood and the symptoms are usually misdiagnosed.

This whole series is about designing well for the cloud. Most people jump straight to "what's the fix" without understanding why the thing is slow — so they fix the wrong layer, throw money at bigger servers, and the problem comes right back. So for every antipattern below I'll explain the mechanism, not just the remedy. Everything here is framework- and cloud-agnostic: it applies whether you're on Azure, AWS, GCP, or your own Kubernetes cluster.

One thread runs through the whole series: this part (the traps) and Part 5 (the cures) are two sides of one coin. Every antipattern here has a matching design pattern that fixes it — No Caching ↔ Cache-Aside, Retry Storm ↔ Circuit Breaker, Noisy Neighbor ↔ Bulkhead. So this article is also a preview of why those patterns exist at all. If you haven't read Part 2 (best practices), many of these antipatterns are simply what happens when a best practice is missing.

First: what makes an antipattern an antipattern

Three properties show up every time:

  • It works in development. With one user and a tiny dataset, the slow thing is fast enough. You ship it. The problem is invisible until scale arrives.
  • It degrades non-linearly. Double the traffic and latency more than doubles. The system doesn't get gradually slower — it falls off a cliff, because something (a connection pool, a thread pool, a database, a downstream service) saturates.
  • The obvious fix is the wrong one. The instinct is "scale up — buy a bigger box." Sometimes that buys you time. But an antipattern is a shape problem, not a size problem. Bigger hardware just moves the cliff a little further out; it doesn't remove the cliff.

That last point is the whole reason this article matters. Cloud resources are elastic and metered, so the cost of a bad shape compounds: you pay for the waste, you pay to scale around the waste, and you still get the latency. Fix the shape and the bill and the latency both drop.

Why antipatterns hide until they don't load (requests / second) → latency → healthy: scales gently antipattern: fine, fine, fine… cliff you test here saturation point

Fig 1 — An antipattern's latency curve stays flat through your test load, then goes vertical when a hidden resource saturates. Scaling up just slides the dashed line right.

Throughout, I'll use the same four-part shape for each antipattern, so you can scan: In plain words · How to spot it · Why it happens · The fix.

1. Busy Database

Offloading too much processing onto the data store.

In plain words. The database is doing work that isn't really database work — heavy formatting, business rules, giant stored procedures, JSON munging, report generation. A database is brilliant at storing, indexing, and querying data. It is an expensive, hard-to-scale place to run general computation.

How to spot it. CPU on the database server is pinned while your application servers sit nearly idle. You have stored procedures hundreds of lines long full of IF logic and string manipulation. Adding application servers doesn't help throughput at all, because the bottleneck is the one database everyone shares.

Why it happens. It's convenient. The data is right there, so "just do it in SQL" feels efficient — one round trip instead of fetch-then-process. And databases are good at set-based work, so it's tempting to keep pushing more in. The trap: your app tier scales out cheaply (add more stateless servers), but your database scales up (one big expensive box) and is the hardest part of the system to replace.

The fix. Move computation that isn't intrinsically data-bound into the application tier, which scales horizontally. Keep in the database only what genuinely benefits from being there — filtering, aggregation, joins over large sets that would be wasteful to pull across the network. The test: "does this operation need to be next to the data?" If not, it belongs in code you can scale by adding cheap servers.

2. Busy Front End

Doing resource-intensive work on the threads that should be answering users.

In plain words. Your web/front-end tier handles incoming requests using a limited pool of threads. If you make those same threads do heavy, slow work — image processing, report building, calling a slow external API and waiting — you starve the pool. New requests queue up behind the busy work and users see timeouts, even though the actual web serving is trivial.

How to spot it. Request latency spikes under load while CPU isn't even maxed (the threads are blocked, not busy computing). Thread-pool exhaustion warnings. The app feels "stuck" — it's not crashed, it just can't accept new work because every worker is tied up.

Why it happens. It's the path of least resistance — the request came in here, so finish the job here. Nobody wants to set up a queue and a separate worker just to resize an image. But the front end's job is to accept requests quickly and respond quickly. Long tasks don't belong on the request path.

The fix. Move expensive or long-running work off the request thread. Drop a message on a queue and return immediately ("we're processing your report, we'll notify you"); a separate pool of background workers picks it up and scales independently. This is the Queue-Based Load Leveling and Competing Consumers pattern combo — and it's the single most common cure in cloud architecture. The front end stays responsive; the heavy lifting happens elsewhere.
Two sides of the same mistake: Busy Database and Busy Front End are mirror images. One pushes too much work down into the data store; the other keeps too much work up on the request threads. The cure for both is the same idea: put each kind of work on the tier that's built for it and can scale for it.

3. Chatty I/O

Lots of small network calls where a few big ones would do.

In plain words. Every network request has a fixed cost — connection setup, headers, round-trip time, serialization — that you pay whether you send one byte or one megabyte. If your code makes hundreds of tiny calls (one per row, one per field, one per item in a loop), you pay that fixed cost hundreds of times. The data transfer is tiny; the overhead is everything.

How to spot it. A single user action triggers dozens or hundreds of database queries or API calls. The classic tell is the N+1 query problem: you fetch a list of 100 orders (1 query), then loop and fetch each order's customer one at a time (100 more queries). Network traces show a storm of small requests. Latency is dominated by count of calls, not size of data.

Why it happens. ORMs and lazy-loading make it invisible — order.customer.name looks like a field access but secretly fires a query. Each call seems cheap in isolation, so it hides in a loop. It also tests fine: with 5 orders, 6 queries is nothing.

The fix. Batch and combine. Fetch related data in one query (a join, or an IN (...) over all the IDs at once). Use bulk endpoints instead of per-item calls. Design coarse-grained APIs that return what a screen needs in one round trip, not chatty fine-grained ones. Rule of thumb: one user action should map to a small, fixed number of network calls — not a number that grows with your data.

4. Extraneous Fetching

Pulling back more data than you actually need.

In plain words. You ask for everything and use a little. SELECT * when you need two columns. Fetching all 10,000 products to count them, or to show page 1 of 20. Loading a customer's entire order history to display their name. The network, the database, and your memory all do work that gets thrown away.

How to spot it. Queries return far more rows or columns than the screen uses. High network egress and memory use that doesn't match what the user sees. Pagination done in application code (fetch all, then take 20) instead of in the query. Aggregations (counts, sums) done by pulling raw rows and looping.

Why it happens. It's easier to fetch a whole object than to specify exactly what you need, and "we might need the other fields later" feels safe. Generic repository methods (getAll()) encourage it. Again: harmless at small scale, brutal at large scale where "the whole table" is millions of rows.

The fix. Fetch only what you need: select specific columns, filter and paginate in the query (WHERE, LIMIT/OFFSET or keyset pagination), and let the database do aggregation (COUNT, SUM, GROUP BY) instead of pulling rows to count them. If you only need a number, ask for the number. This is the close cousin of Chatty I/O — Chatty is too many calls, Extraneous is too much per call — and real systems often have both.

5. Improper Instantiation

Creating and destroying objects that were meant to be shared and reused.

In plain words. Some objects are expensive to create — they open connections, set up TLS, build pools, parse config. They're designed to be created once and reused for the life of the app: HTTP clients, database connection pools, serializers. If you instead create a fresh one on every request, you pay the expensive setup over and over, and you can exhaust the underlying resource entirely.

How to spot it. The classic catastrophe is socket exhaustion from creating a new HTTP client per request: each one opens connections that linger in TIME_WAIT, and under load you run out of ports and start getting connection failures that look random and intermittent. Also: connection-pool churn, sudden latency from repeated handshakes, garbage-collection pressure from constant allocation.

Why it happens. "Create it where you use it" is a clean-looking habit, and new SomeClient() inside a method reads innocently. The cost is hidden inside the constructor. Dependency-injection defaults sometimes get this wrong too (registering a singleton-intended object as transient).

The fix. Share the instances that are meant to be shared. Create one HTTP client, one connection pool, one serializer at startup and reuse it everywhere (singleton lifetime in your DI container, or a static/module-level instance). Read the docs: most SDK clients explicitly say "this object is thread-safe and intended to be reused." Believe them.

6. Monolithic Persistence

Using one data store for data with completely different usage patterns.

In plain words. You put everything in one database — transactional order data, append-only logs, full-text search content, session state, analytics events, big binary files. Each of those has a different access pattern, and forcing them into one store means that store can't be optimized for any of them. Worse, heavy use of one kind of data (say, a reporting query scanning logs) steals resources from another (customers trying to check out).

How to spot it. One database with wildly mixed workloads. Analytics or reporting queries slowing down transactional traffic. A single store that's simultaneously your system of record, your search index, your cache, your queue, and your blob store. Contention where unrelated features fight over the same I/O.

Why it happens. One database is simpler to operate, back up, and reason about — at first. "Just add a table" scales socially even when it doesn't scale technically. The different patterns creep in one feature at a time until the single store is doing five jobs badly.

The fix. Use the right store for each job — polyglot persistence. Transactional data in a relational DB; documents in a document store; hot key-value data in a cache like Redis; search in a search engine; large files in object storage (S3/Blob); high-volume events in a log or stream. Separate the stores so heavy use of one doesn't starve the others, and each can be scaled and tuned independently. The cost is operational complexity — so split along real usage-pattern boundaries, not for the sake of it.

7. No Caching

Recomputing or re-fetching the same thing over and over.

In plain words. The same data gets read far more often than it changes — a product catalog, a config value, a user's profile, the result of an expensive query. Without a cache, every single request pays the full cost of fetching or computing it again, hammering the database (see: Busy Database) and adding latency for no reason.

How to spot it. The same query runs thousands of times a second with identical results. Database load that's almost all reads of slowly-changing data. Latency that would vanish if the answer were just sitting in memory. Big traffic spikes translating directly into database spikes.

Why it happens. Caching adds complexity — you have to decide what to cache, how long, and how to invalidate it ("there are only two hard problems in computer science…"). So it gets deferred. And without it, things work; they're just needlessly slow and expensive.

The fix. Cache read-heavy, change-rarely data close to where it's used — an in-memory cache, or a distributed cache like Redis/Memcached shared across instances. The standard approach is Cache-Aside: check the cache first; on a miss, load from the source and populate the cache; set a sensible expiry so stale data eventually refreshes. Start with the hottest, most-repeated reads. But — see the next note, because naive caching has its own failure mode.
Watch out — Cache Stampede (a bonus antipattern). When a popular cached item expires, every in-flight request misses at once and they all rush the database to recompute it simultaneously — a self-inflicted Retry-Storm-by-another-name. Cures: add small random jitter to expiry times so items don't all expire together, refresh hot items before they expire (proactive refresh), or use a lock so only one request recomputes while the others wait or serve slightly-stale data.

8. Noisy Neighbor

One tenant hogging a disproportionate share of shared resources.

In plain words. In a multi-tenant system — many customers sharing the same servers, database, or cluster — one tenant can blow up the experience for everyone else. A single customer runs a monster report, imports a million rows, or just gets a traffic spike, and suddenly everyone on that shared infrastructure is slow. They're the loud neighbor; everyone else hears the party.

How to spot it. Latency for many customers spikes at once with no overall traffic increase — because one customer's usage jumped. Performance that's unpredictable and seemingly random from any single tenant's view. One account's heavy queries showing up at the top of your slow-query logs right when others complain.

Why it happens. Sharing resources is cheaper than dedicating them, so multi-tenancy is the default for SaaS economics. But shared resources with no isolation means no protection: one tenant's worst day becomes everyone's worst day.

The fix. Isolate and limit. Enforce per-tenant quotas and rate limits so no single tenant can consume more than its share. Use the Bulkhead pattern — partition resources (separate pools, separate queues, even separate database shards) so a failure or overload in one partition can't sink the others, just like watertight compartments in a ship. For your biggest/heaviest customers, consider dedicated resources. The goal: one tenant's behavior is contained to that tenant.

9. Retry Storm (a.k.a. Improper / Aggressive Retries)

Retrying failed requests so hard you turn a small problem into an outage.

In plain words. A downstream service hiccups. Your code retries — good instinct, transient failures are normal in the cloud. But if every client retries immediately, many times, with no delay, you flood the already-struggling service with more traffic exactly when it's least able to handle it. It can't recover because the retries keep it pinned. A blip becomes a full outage, and the outage feeds on itself.

How to spot it. A downstream service's load increases during an incident instead of dropping. Traffic graphs showing synchronized retry spikes (everyone retrying on the same schedule). A service that goes down, comes back for a second, and immediately gets knocked over again by the backlog of retries. Cascading failures spreading across services.

Why it happens. Naive retry logic — "on failure, try again right away, up to 5 times" — is easy to write and feels robust. Nobody's modeling what happens when all clients do it at once. The retries are well-intentioned; collectively they're a denial-of-service attack you launch against yourself.

The fix. Retry politely, and know when to stop.
  • Exponential backoff: wait longer between each attempt (1s, 2s, 4s, 8s…) instead of hammering.
  • Jitter: add randomness to those waits so clients don't all retry in lockstep.
  • Cap the attempts and only retry transient errors (a 503 or timeout, not a 400 "bad request" that'll fail every time).
  • Circuit Breaker: when a service is clearly down, stop calling it for a cool-off period — fail fast, give it room to recover, then test cautiously before resuming. This is the key pattern, and it's what turns a retry storm back into a brief blip.

10. Synchronous I/O

Blocking a thread while you wait for I/O to finish.

In plain words. Your code calls the database, or a file, or another service, and then waits — the thread sits there doing nothing, holding all its memory and its slot in the thread pool, until the response comes back. I/O is slow (milliseconds, an eternity in CPU terms), so that thread is idle most of the time. Under load, all your threads end up blocked waiting, and you can't accept new work even though the CPU is barely doing anything.

How to spot it. Thread-pool exhaustion under load. Low CPU usage but high latency and stalled requests (the giveaway: the machine isn't working, it's waiting). Throughput that's capped by thread count rather than CPU or memory. It's the engine behind Busy Front End, too.

Why it happens. Synchronous code is simpler to read and write — call, get result, continue. Async code is more complex and "infectious" (async tends to spread up the call stack). So blocking calls get written by default, and the cost only shows up when many of them block at once.

The fix. Use asynchronous, non-blocking I/O. Instead of parking a thread while waiting, the thread is released to do other work and is notified when the I/O completes (async/await in most modern languages and runtimes). The same hardware handles vastly more concurrent requests because threads spend their time working, not waiting. For truly long operations, go further: hand the work to a background worker via a queue (Busy Front End's fix) so nothing waits on the request path at all.
Synchronous vs asynchronous I/O — same hardware sync work BLOCKED waiting on I/O work ← 1 thread, 1 request at a time async req A req B req C req A req D ← thread freed during waits; many requests interleaved During each I/O wait, the async thread goes and serves someone else instead of sitting idle. Result: far higher throughput on the same threads — no extra servers needed.

Fig 2 — Synchronous I/O parks a thread for the whole wait. Async hands the thread back so it can serve other requests while the I/O is in flight.

Worked examples: seeing the fix in code

Abstract descriptions are easy to nod along to and hard to recognize in your own codebase. So here are three of the most common antipatterns shown as real before/after code. The point isn't the exact syntax — it's the shape of the change.

Chatty I/O — the N+1 query, before and after

This is the one you'll find in almost every codebase that uses an ORM. The "before" looks completely innocent — it's a loop over a list — which is exactly why it survives review.

# BEFORE — 1 query for the orders, then N more (one per order). 101 round trips.
orders = db.query("SELECT id, customer_id, total FROM orders WHERE status = 'open'")
for order in orders:                      # say this returns 100 rows
    customer = db.query(                   # ← fires a query *every iteration*
        "SELECT name FROM customers WHERE id = ?", order.customer_id
    )
    print(order.id, customer.name)

With 100 orders that's 101 queries — and each query pays the full network round-trip to the database. At 5ms per round trip that's half a second of pure waiting, most of it overhead. The fix is to ask for everything you need in one (or a few) trips:

# AFTER (option A) — one JOIN. 1 round trip total.
rows = db.query("""
    SELECT o.id, o.total, c.name
    FROM orders o
    JOIN customers c ON c.id = o.customer_id
    WHERE o.status = 'open'
""")
for r in rows:
    print(r.id, r.name)

# AFTER (option B) — if a join is awkward, batch the second query with IN(...).
orders = db.query("SELECT id, customer_id, total FROM orders WHERE status='open'")
ids = [o.customer_id for o in orders]
customers = db.query(                      # ← 1 query for ALL customers, not N
    "SELECT id, name FROM customers WHERE id IN (?)", ids
)
by_id = {c.id: c.name for c in customers}
for o in orders:
    print(o.id, by_id[o.customer_id])

Two queries instead of a hundred and one. If you use an ORM, the same fix shows up as "eager loading" — telling the ORM to JOIN/preload the related entity instead of lazy-loading it inside the loop (e.g. .include(), .joinedload(), .prefetch_related(), depending on your stack). The lesson: a user action's query count should be constant, not proportional to the number of rows.

Improper Instantiation — the per-request HTTP client

This one looks like good hygiene ("create what you use, where you use it") and causes one of the most baffling production failures: intermittent connection errors under load that you can't reproduce locally.

# BEFORE — a new client per call. Each opens connections that linger in TIME_WAIT;
# under load you exhaust ports/sockets and get random connection failures.
def get_user(user_id):
    client = HttpClient()                  # ← expensive setup, every single call
    return client.get(f"/users/{user_id}")

# AFTER — one shared, reused client for the life of the process.
client = HttpClient()                       # ← created ONCE at startup

def get_user(user_id):
    return client.get(f"/users/{user_id}")  # reuses pooled connections

The shared client keeps a pool of warm connections and reuses them, so you skip the TCP + TLS handshake on every call and you stop leaking sockets. Almost every SDK's docs say the client is thread-safe and meant to be reused as a singleton — this failure mode is so common that it has launch-blog-level warnings in most HTTP libraries.

Retry Storm — naive retries vs. backoff with a breaker

The "before" is what most people write the first time they add resilience. It's the well-intentioned code that turns a 2-second downstream blip into a 20-minute outage.

# BEFORE — retry immediately, forever-ish. When the downstream is struggling,
# every client does this at once and keeps it pinned so it can never recover.
def call_service():
    for attempt in range(5):
        try:
            return http.get("/api")
        except Exception:
            continue                        # ← no wait, no jitter, retries 4xx too

# AFTER — backoff + jitter + only retry transient errors + a circuit breaker.
import random, time

def call_service():
    if breaker.is_open():                   # service known-down → fail fast, don't pile on
        raise ServiceUnavailable()
    for attempt in range(5):
        try:
            resp = http.get("/api")
            breaker.record_success()
            return resp
        except TransientError:              # only 5xx / timeouts — NOT 400 "bad request"
            breaker.record_failure()
            wait = min(2 ** attempt, 30) + random.uniform(0, 0.5)  # 1s,2s,4s… + jitter
            time.sleep(wait)
    raise ServiceUnavailable()

Three changes do the work: wait longer each time (so you stop hammering), add jitter (so a thousand clients don't retry in perfect unison), and open the breaker when the service is clearly down (so you stop calling it entirely and give it room to recover). Note also that a 400 Bad Request is not retried — it'll fail identically every time, so retrying it is pure waste.

Antipatterns travel in packs

The biggest reason these are worth learning together: in real systems they rarely appear alone. One causes the next, and the failure you actually see is several links down the chain from the root cause. Chasing the symptom instead of the chain is how teams spend a week "optimizing the database" when the real problem was upstream.

A very common cascade, start to finish:

  1. No Caching means the same data is fetched on every request.
  2. Fetching it uses Chatty I/O (N+1 queries) and Extraneous Fetching (whole rows when you needed a name), so each request hits the database dozens of times for data it'll throw away.
  3. Those database calls are Synchronous I/O, so each one blocks a request thread while it waits.
  4. Blocked threads pile up — that's Busy Front End — and the thread pool saturates, so new requests can't even be accepted.
  5. Clients see timeouts and retry aggressively, a Retry Storm, which sends even more load at the already-drowning database.
  6. If it's multi-tenant, one heavy tenant kicked this off and now it's a Noisy Neighbor event taking down everyone.

Six antipatterns, one outage. The symptom on the dashboard is "everything is slow and the database CPU is at 100%," so the instinct is to scale the database. But the root cause was the missing cache and the chatty fetch at step 1 — fix those and the entire cascade never starts. This is why the diagnosis section below matters more than any single fix: find the first domino, not the last.

The cost angle nobody mentions. On a single owned server, an antipattern wastes capacity you already paid for. In the cloud, you pay for the waste directly — more compute hours, more database units, more egress, more requests — and then you pay again to autoscale around it. A chatty, cacheless service can cost 5–10× what a clean one costs to serve the same traffic. Fixing the shape is often the single biggest line-item you can cut from a cloud bill, and it improves latency at the same time. Performance and cost are the same problem wearing two hats.

A few more you'll meet in the wild

The ten above are the canonical set, but the same "looks fine, scales badly" shape shows up in other forms. Quick hits:

AntipatternIn one lineThe cure
Cache StampedeA hot cache key expires and every request rushes the DB to refill it at once.Expiry jitter, proactive refresh, single-flight lock.
God Service / Distributed Monolith"Microservices" so tightly coupled they must deploy together and call each other synchronously in long chains.Real bounded contexts; async messaging between services.
Chatty Service CompositionOne user request fans out into a long synchronous chain of service-to-service calls.Aggregate/gateway, async events, denormalized read models (CQRS).
No BackpressureAccepting work faster than you can process it until memory/queues blow up.Bounded queues, load shedding, rate limiting at the edge.
Premature/Manual ScalingSizing for peak 24/7, or scaling by hand, instead of autoscaling to demand.Autoscaling on real metrics; scale to zero where possible.
Logging on the hot pathSynchronously writing verbose logs/metrics inside the request, adding I/O to every call.Async/buffered logging; sample high-volume events.

The cheat sheet: symptom → antipattern → fix

In practice you don't start from "I have a Busy Database." You start from a symptom — something's slow or expensive — and work backward. This table goes the way real debugging goes.

What you observeLikely antipatternFirst thing to try
DB CPU pinned, app servers idleBusy DatabaseMove non-data logic to the app tier
High latency but low CPU; threads exhaustedSynchronous I/O / Busy Front EndMake I/O async; offload long work to a queue
One action = hundreds of queriesChatty I/O (N+1)Batch / join; eager-load related data
Huge result sets, most unusedExtraneous FetchingSelect fewer columns; filter + paginate in the query
Random connection failures under loadImproper InstantiationReuse one shared client/pool
Reporting query slows down checkoutMonolithic PersistenceSplit stores by usage pattern
Same query runs constantly, same resultNo CachingCache-Aside on the hottest reads
Many tenants slow when one spikesNoisy NeighborPer-tenant quotas; Bulkhead isolation
Downstream load rises during an outageRetry StormBackoff + jitter + Circuit Breaker

How to actually find these in your system

You can't fix what you can't see, and the cruel part is that antipatterns are nearly invisible until load reveals them. So build the visibility before you need it:

  • Load test like you mean it. Most antipatterns only appear under concurrency. Test at realistic (and beyond-realistic) traffic, watching latency percentiles (p95, p99), not averages — averages hide the cliff.
  • Trace a single request end to end. Distributed tracing (OpenTelemetry and friends) shows you the N+1 storm, the chatty service chain, the slow synchronous call — laid out visually. The moment you see 200 tiny DB spans for one page load, Chatty I/O stops being abstract.
  • Watch the boring metrics. Thread-pool saturation, connection-pool usage, queue depth, cache hit ratio, per-tenant resource use, and the gap between CPU usage and latency. That last one — high latency, low CPU — is the universal "you're waiting, not working" signal.
  • Read your slow-query and access logs. The same query a thousand times = missing cache. One tenant at the top of every slow log = noisy neighbor. Giant result sets = extraneous fetching. The evidence is usually already there.
The golden signal: if latency is high but CPU, memory, and network are all comfortable, you are almost certainly waiting on something — blocked threads, saturated pools, a slow downstream. That points straight at Synchronous I/O, Busy Front End, or Retry Storm. Conversely, if one resource is pinned (DB CPU, one tenant, one service), you've got a hot-spot antipattern — Busy Database, Noisy Neighbor, Monolithic Persistence.

FAQ

Aren't some of these just "good vs bad code"? Why call them antipatterns?

Because they're not random bugs — they're repeatable design choices that look correct and pass review. Naming them turns "this feels slow" into a shared vocabulary: you can say "that's Chatty I/O" and everyone knows the symptom, cause, and fix. That's the whole value of a pattern language, good or bad.

Can't I just scale up / add servers instead of fixing these?

Sometimes, briefly. But an antipattern is a shape problem, not a size problem — bigger hardware just moves the saturation cliff a bit further out (Fig 1). You pay more, and the problem returns at higher load. Worse, some of them (Retry Storm, Cache Stampede, Noisy Neighbor) get worse with more clients. Fixing the shape usually cuts both cost and latency.

Is putting logic in the database always wrong (Busy Database)?

No. Set-based work that's intrinsically data-bound — filtering, joining, aggregating large sets — belongs in the database; pulling millions of rows to the app to do it would be Extraneous Fetching. The antipattern is putting general computation and business rules there, on the one tier that's hardest to scale. The test: "does this need to be next to the data?"

Doesn't caching just create stale-data bugs?

It can, which is why invalidation is famously hard. The answer is to cache data that changes rarely relative to how often it's read, set sensible expiries, and accept that "a few seconds stale" is fine for most reads (a product name, not a bank balance). And watch for Cache Stampede when hot keys expire. No caching at all is usually the bigger mistake.

How do these relate to the cloud design patterns (Part 5)?

One-to-one, mostly. Each antipattern has a matching pattern that cures it: No Caching ↔ Cache-Aside, Retry Storm ↔ Circuit Breaker + Retry-with-backoff, Noisy Neighbor ↔ Bulkhead + Throttling, Busy Front End ↔ Queue-Based Load Leveling + Competing Consumers, Monolithic Persistence ↔ polyglot persistence + CQRS. Part 5 is the cure catalog for everything here.

Takeaways

  • Antipatterns hide in dev and bite at scale. They pass tests, then go vertical when a hidden resource saturates. Don't trust "it's fast on my machine."
  • Shape beats size. Almost every one of these is fixed by changing the design, not the hardware. Scaling up is a stopgap, not a fix.
  • Put each kind of work where it belongs. Data work near data, request handling on light threads, long work on background queues, hot reads in a cache, different data in different stores.
  • Be a polite client. Few coarse calls beat many chatty ones; fetch only what you need; retry with backoff and a circuit breaker; reuse expensive objects.
  • Build visibility first. Load test at p99, trace one request end to end, and watch the high-latency-low-CPU signal. The antipatterns are already in your logs — you just have to look.

That's Part 3 of Design for the Cloud. You've now seen the architecture styles (Part 1) that decide where these traps can occur, the best practices (Part 2) that prevent most of them, and the traps themselves. Next: Part 4 — responsible engineering, where reliability, security, cost, and sustainability become first-class design concerns — and then the payoff, Part 5, the documented cures for every antipattern here. The bad patterns are easier to understand once you know them by name. The good ones are coming.

References

Extra reads

← prev: best practices next: responsible engineering →
© cvam — written in plaintext, served warm