Why a single counter row bottlenecks under write volume
coreintermediateA counter looks like the cheapest thing in a database and becomes one of the most expensive under load, for a reason that has nothing to do with how much data it holds. Incrementing a counter means updating one specific row, and a database serialises concurrent updates to the same row: each transaction takes a row lock, applies its change, and holds that lock until it commits. So the maximum increment rate for one row is one divided by the time each transaction holds the lock — if a transaction holds it for two milliseconds, that row accepts about five hundred increments per second no matter how many application servers, connections or CPU cores you add. Everything else waits, and the waiting is what makes it worse: contending transactions occupy connections while blocked, so a hot counter can exhaust a connection pool and slow down operations that have nothing to do with it. The effect is invisible at low volume and non-linear at high volume, because queueing delay rises sharply as arrival rate approaches service rate. The counter is also usually not the point of the transaction — it is a view counter updated alongside a page load, or a like count updated alongside an insert — so the whole transaction's duration, including everything else it does, is what determines how long the lock is held. That is why the first fix is often not sharding the counter but shortening the transaction that touches it, and why the real fixes in the next concept all work by removing the requirement that every increment reach the same row.
Think of it as
One turnstile at a stadium. It does not matter how many people are outside or how many staff you hire; the throughput is set by how long each person takes to pass through it. Add a hundred more people and nothing speeds up — the queue just gets longer, and the queue itself starts causing problems, blocking the road, filling the concourse. A hot counter row is that turnstile, and the transaction time is how long each person takes.
What we're doing: Watch a counter that is fine at 200 increments per second stop working at 600.
- 8
- Forty percent utilisation of a serialised resource is comfortable, which is why the feature ships and looks healthy for months. Nothing in the design says how close to the ceiling it is running.
- 16
- This is the shape that makes contention dangerous. Between 80% and 96% utilisation the added latency grows roughly sixfold for a 20% traffic increase, so a normal growth month moves the system from fine to failing.
- 24
- The blast radius is the important part: the failure does not stay inside the counter. Blocked transactions hold connections, and once the pool is exhausted every query in the application competes for what is left.
Why this works: The point is that a hot counter has a hard ceiling that no amount of horizontal scaling raises, and that the approach to that ceiling is non-linear rather than gradual. Knowing the transaction's lock hold time gives you the number, which turns "is this counter a risk" from a guess into arithmetic you can do before it becomes an incident.
Incrementing the counter at the start of a long transaction
Wrong
Better
What you see: A counter on a popular row starts timing out, and profiling shows the `UPDATE` itself is fast. The slow part is waiting for the lock, held by other transactions doing entirely unrelated work in the same block.
Why: A row lock is held until commit, so the counter inherits the duration of everything else in its transaction. Moving the increment into its own short transaction — or out of the request path altogether — raises the ceiling by the ratio of the transaction lengths, which is often more than an order of magnitude for a one-line change.
- App server 1
- leads to Row lock on posts.id = 9812
- App server 2
- leads to Row lock on posts.id = 9812
- App server 3
- leads to Row lock on posts.id = 9812
- Row lock on posts.id = 9812 — one holder at a time
- leads to view_count
- on error, leads to Everyone else waits
- view_count — ceiling = 1 / lock hold time
- Everyone else waits — holding connections while blocked
Why the usual scaling levers do nothing here
Remember: A counter's throughput ceiling is one divided by how long the transaction holds its row lock, and no amount of horizontal scaling raises it, because the contended resource is one row rather than compute. Lock hold time is the whole transaction, so an unrelated slow statement in the same block lowers the ceiling directly. The approach to that ceiling is non-linear, and blocked transactions consume connections, so a hot counter degrades operations that have nothing to do with it. Shortening the transaction is the cheap fix; removing the single-row requirement is the real one.
See also: sharded batched and approximate counters · concurrency control mechanisms · littles law · resource limit checklist · stampede hot keys and memory pressure

