Home → Engineering
Worker Sharding: The Right Way to Split Work
Four workers are running. There are 200,000 jobs in the queue. You look at the dashboards: one is at 100% CPU, three are nearly idle. Adding workers does not change this picture.
- A lock manages collisions; sharding eliminates them. Pick the second when you can.
- Modulo is the easiest and the most fragile. Change the worker count and most keys move.
- Distribution depends on the shape of your data. If one customer is 40% of the load, no hash saves you.
- Rebalancing is where double processing is born. Jobs must be idempotent.
First: is a queue not enough?
In most cases it is, and sharding gets written for nothing. The distinction is simple:
- Jobs are independent
- Order does not matter
- Workers hold no state
Example: generating a report PDF, resizing an image.
- Jobs for the same entity must be processed in order
- The worker keeps state in memory (cache, session)
- Touching the same entity in parallel corrupts data
Example: movements on a wallet, status transitions of an order.
The key sentence: the point of sharding is not speed, it is removing collisions. With locking, everyone runs to the same door and queues; with sharding, they were already entering through different doors.
Method 1: modulo (easiest, most fragile)
shard = hash(user_id) % WORKER_COUNT
# worker 2 only processes:
SELECT * FROM jobs
WHERE status = 'pending'
AND MOD(hashtext(user_id::text), 4) = 2
ORDER BY created
LIMIT 100;
It works, there is no lock, the code is short. Trouble starts when you change scale.
The moment you say % 5 instead of % 4, roughly
80% of keys move to a different worker. That has two consequences:
- In-memory caches on the workers are thrown away; the system slows down temporarily.
- If an old worker is still using the old distribution during the transition, the same job can be picked up by two workers.
The sneakiest part: if each worker reads N from its own environment
variable and the rollout is gradual, for a while some think it is 4 and others think
it is 5.
Method 2: a fixed number of logical shards (best in practice)
My favourite solution and surprisingly simple: decouple the shard count from the worker count. Pick a fixed, generous number — say 64 — and hand out shard numbers to workers.
SHARD_COUNT = 64 # never changes
shard = hash(user_id) % 64
# with 4 workers:
worker 0 -> shards 0..15
worker 1 -> shards 16..31
worker 2 -> shards 32..47
worker 3 -> shards 48..63
# add a 5th worker and the shards are redistributed,
# but a key's SHARD does not change. Only its owner does.
The difference is subtle and important: the key → shard mapping stays fixed and only the shard → worker mapping changes. That makes per-shard state, caches and progress information transferable. Kafka's partition model is the same idea.
One rule when choosing the shard count: make it several times the number of workers you can imagine ever running. 64 or 128 is plenty for most systems.
Consistent hashing: how to hand shards to workers
The fixed-shard approach leaves one question open: how do you distribute 64 shards across 4 workers? The easiest way is ranges (0–15, 16–31...), but when a fifth worker appears those ranges are recomputed and nearly every shard changes hands.
Consistent hashing solves exactly that. The idea: place both the workers and the keys on an imaginary ring. Each shard belongs to the first worker clockwise from it.
Ring (0 .. 2^32):
worker-A ● ● worker-B
↘ shards 7,12,31 ↙
●
worker-C
# When worker-D is added:
# Only the shards of D's NEIGHBOUR on the ring move.
# Everything else stays untouched.
owner(shard) = the first worker clockwise from hash(shard)
| 4 → 5 workers | Keys that move |
|---|---|
Modulo (% N) | ~80% |
| Range splitting | ~40–60% |
| Consistent hashing | ~20% (that is, 1/N) |
A practical detail: each worker must be placed on the ring at 100–200 virtual points, not one. With a single point the distribution is left to luck and one worker can end up with three times another's load. Virtual nodes flatten it, and most libraries do this by default.
Why it matters: the reconnect storm
10,000 sessions spread across 4 streaming engines. Load grew, a fifth instance was added. We were using modulo.
Result: 8,000 sessions changed owner. All disconnected at once, all reconnected at once, all re-established their subscription state at once. An operation meant to add capacity left the system without capacity for 40 seconds.
After moving to consistent hashing the same operation affected 2,000 sessions — and we handed those over gradually too. This is a thundering herd problem, and the fix is the same as there: staggering and randomness.
Rebalancing: hand over gradually
Consistent hashing reduces how many shards move but does not make it zero. The ones that do move should be handed over one at a time, not all at once:
for (shard : shardsToMove) {
oldOwner.stop(shard); // stop taking new work, finish what you hold
oldOwner.confirmDrained(shard); // ← wait for this
newOwner.start(shard);
Thread.sleep(200); // ← breathe before the next shard
}
That 200 ms pause looks trivial but its effect is large: a handover of 16 shards spreads over 3 seconds and the systems underneath (database, provider connections) see steady load instead of a single wave.
And during the handover double processing will happen — the old owner may still be finishing a message while the new one starts. That is why idempotency is not negotiable (details).
Method 3: a claim table (dynamic and visible)
Instead of fixed shards, workers "claim" the work themselves, in one atomic update:
UPDATE jobs
SET owner = :worker_id,
claim_expires = now() + interval '5 minutes',
status = 'processing'
WHERE id IN (
SELECT id FROM jobs
WHERE status = 'pending'
OR (status = 'processing' AND claim_expires < now()) -- a dead worker's job
ORDER BY created
LIMIT 50
FOR UPDATE SKIP LOCKED -- ← the key line
)
RETURNING *;
SKIP LOCKED is the trick: it skips without waiting any rows
another worker has locked. So workers pick different jobs without ever blocking each other.
The second benefit: thanks to claim_expires, a dead worker's jobs free
themselves after five minutes. You do not need a separate health check.
The cost is the loss of ordering: two jobs for the same user can land on different workers. If order matters, this method is not for you.
Two cases from the field
1. One customer, four workers, a hash that did nothing
A notification system was split by customer_id % 4. One enterprise customer
alone produced about 40% of the daily notifications.
Result: the worker that customer hashed to was permanently at 100% CPU while the other three sat idle. We raised the worker count to 8 — nothing changed, because that customer still landed on exactly one worker.
-- before
shard = hash(customer_id) % 64
-- after
shard = hash(customer_id + ":" + notification_id) % 64
Now that customer's notifications spread across all shards. We could do this because notifications needed no ordering guarantee. If they had, this fix would not have been available and a dedicated, larger worker for that customer would have been the answer.
2. The day ordering broke
Order status updates were written to a queue: preparing → shipped → delivered. As load grew, the worker count was increased, and a week later support reported something odd: some orders were going back from "delivered" to "shipped".
The reason was straightforward: two messages for the same order had landed on two different workers, and the "shipped" message was processed after the "delivered" message that was produced later.
We fixed it in two layers:
- Shard by order id. All messages for the same order go to the same worker and are therefore processed in order.
-
Guard the transition. Even with sharding, a rebalance can break the
order one day, so let the database protect itself too:
UPDATE ... WHERE status_rank < :new_rank. A late old message cannot write.
The second point is the same idea as a fencing token: entrust correctness to order, not to timing.
Rebalancing: the most dangerous moment
When you add or remove workers, shards change hands. In that exact second two workers can believe they own the same shard. It is sharding's most critical moment and its least tested part.
- Stop before handing over. The new owner should not start until the old owner has confirmed "shard 17 is no longer mine". On Kubernetes that means honouring the shutdown signal and allowing a sensible grace period.
- Write idempotent jobs. This one is not negotiable. During a rebalance a job will be processed twice; the system must not be harmed by it.
- Manage the distribution from one place. If each worker computes its own share, they will temporarily disagree. Keep the assignment in one place (a table, a coordinator, a lease) and have workers read it from there.
Three things worth measuring
| Measure | What it shows | Bad signal |
|---|---|---|
| Pending jobs per shard | Whether the distribution is even | One shard five times the others means a hot key |
| CPU per worker | Whether the load is genuinely split | One busy, the rest idle |
| Time a job waits in the queue | Where latency accumulates | Good average but bad 95th percentile means one shard is stuck |
That third row matters: do not look at the average. With three of four workers idle, average latency looks great; the customer on the congested shard gets their notifications half an hour later.
Checklist AI agent task list
- Do you actually need sharding, or would a plain queue do?
- Is the shard count independent of the worker count?
- Does the key choice preserve ordering? (Same entity, same shard?)
- What percentage of the load is the biggest key in your data? (Have you measured it?)
- When a worker dies, how long before its shard is taken over?
- Are jobs idempotent? (Rebalancing will cause double processing.)
- Does one place decide the distribution, or does each worker compute its own?
- Do you have per-shard metrics, or only totals?
Conclusion
Sharding is a more elegant answer than locking: instead of managing collisions, it removes them. But it is not free — you have to think about scale changes, dead workers and uneven data.
Back to the opening picture: the reason those three workers were idle was not capacity, it was the choice of key. The hard part of sharding is not writing the code but choosing the right key — and the right key is always chosen by looking at the real distribution of the data, never by guessing.