How to Stop Your API from Hammering the Database Under Concurrent Load
A practical walkthrough of Redis caching and distributed locks
How to Stop Your API from Hammering the Database Under Concurrent Load
A practical walkthrough of Redis caching and distributed locks
If you’ve ever had an API endpoint that works perfectly under normal load but starts falling apart when multiple requests hit it at the same time, this post is for you.
The problem is something called a cache stampede. It’s sneaky because caching is supposed to be the solution, not the problem. But if you don’t handle cache misses carefully, your cache can actually make things worse under high concurrency.
Let’s break it down.
The Problem Say you have this endpoint:
GET /live-order-summary
It returns a count of orders grouped by status — pending, in-transit, delivered, cancelled. A dashboard polls it every few seconds.
You added Redis caching to avoid hitting the database on every request. Smart move. But here’s what happens when the cache expires:
- 1000 users have the dashboard open
- The cache TTL runs out
- All 1000 requests arrive, find an empty cache, and go straight to the database
- The database runs the same heavy aggregation query 1000 times at once
This is the stampede. Your cache expired, and instead of one database query, you got 1000.
The Fix: Cache + Distributed Lock
The idea is simple — when the cache is empty, only one request should query the database. Everyone else should either wait or get served with slightly old data.
Here’s the full flow, from request to response:

Step 1: Check the Cache First
This part is the same as any basic caching setup. Check Redis before doing anything else.
key = "live_order_summary"
If data is there, return it. Request done. Database never touched. This is where the vast majority of requests should end up, and response time here is just a few milliseconds.
The key is stored with a TTL — once it expires, the next request finds an empty cache and the whole lock flow kicks in to refresh it. How long you set the TTL depends on how fresh the data needs to be. For a summary dashboard, something in the range of 30–60 seconds is usually a good fit, but ultimately this is a business call — how stale is too stale for your users?
The trouble only starts on a cache miss.
Step 2: The Distributed Lock
When the cache is empty, you need a way to ensure that only one request runs the database query while the others wait.
That’s what the lock is for.
Redis has an atomic command called SET NX EX That does this perfectly:
- NX — only sets the key if it doesn’t already exist
- EX — auto-deletes the key after a timeout (your safety net if something crashes)
Because it’s atomic, only one request can ever win. There’s no gap where two requests both check, both see no lock, and both set it. One wins, the rest lose.
If You Won the Lock
You get to query the database. Do the work, store the result in Redis, then release the lock.
One thing is really important here: always release the lock, even if something goes wrong. Use a finally block, a defer, or whatever your language offers to guarantee cleanup. If your database query fails and you don't release the lock, every other request just sits and waits until the lock auto-expires. That's a bad time.
If You Lost the Lock
The wait loop works like this:
- Sleep for 500ms
- Check if the lock has been released
- If yes, read from the cache and return whatever is there
- If no, sleep again and retry
- If 3 seconds pass with no result, return an empty response and move on
That’s it. No stale data fallback, no shortcuts. The lock-loser waits until the lock-winner finishes, then reads from the freshly populated cache. All 1000 requests end up sharing the result of a single database query.
The Full Picture

No matter how many requests arrive during a cache miss, the database gets hit exactly once. Every other request waits and reads the result from Redis. That’s the whole point.
A Few Things to Keep in Mind
Set a reasonable lock TTL. It should be longer than your slowest expected database query, but not so long that a crash blocks everything for minutes. 3 seconds is a reasonable starting point for most cases.
The finally block is not optional. Always release the lock in a guaranteed cleanup block. Relying only on the TTL is not a strategy — it's a fallback for when things go wrong.
The timeout in the wait loop matters. If something goes wrong with the lock winner and it never populates the cache, you don’t want lock-losers waiting forever. Return an empty response after the timeout and let the client retry.
This pattern shines for read-heavy, aggregation endpoints. It’s not meant for writes, real-time data, or anything where serving an empty response could cause harm.
Wrapping Up
Cache stampedes are easy to miss because caching itself feels like the right thing to do — and it is. The stampede only happens when you don’t handle the miss carefully.
The distributed lock keeps it clean: one request does the work, everyone else waits and shares the result. No duplicate queries, no database overload, no complicated stale-data logic.
메타데이터
- post_id
- 4d75a01383de
- slug
- how-to-stop-your-api-from-hammering-the-database-under-concurrent-load-4d75a01383de
- url
- https://medium.com/@suvrajitkarmaker/how-to-stop-your-api-from-hammering-the-database-under-concurrent-load-4d75a01383de
- canonical_url
- https://medium.com/@suvrajitkarmaker/how-to-stop-your-api-from-hammering-the-database-under-concurrent-load-4d75a01383de
- author_url
- https://medium.com/@suvrajitkarmaker
- status
- ok
- fetched_at
- 2026-06-20 20:29:01