Skip to content
Get started
Get started

Sharding Deep Dive: Multi-Core Job Distribution

blog · architecture

One queue per core, not one lock for all.

Instead of a single global queue behind a lock, bunqueue partitions jobs across independent shards with FNV-1a hashing, sized to your CPU cores. Here is how it works.

A single priority queue becomes a bottleneck when multiple workers pull jobs simultaneously. Lock contention grows with concurrency. Sharding solves this by partitioning the job space:

  • Each shard has its own priority queue
  • Workers can pull from different shards concurrently
  • Lock contention is reduced by a factor of N (shard count)

The number of shards is calculated at startup based on available CPU cores, rounded up to the nearest power of 2:

function calculateShardCount(): number {
const cores = navigator.hardwareConcurrency || 4; // fallback: 4
// Find next power of 2 >= cores, capped at 64
let shards = 1;
while (shards < cores && shards < 64) {
shards *= 2;
}
return shards;
}
CPU CoresShard Count
11
22
3-44
5-88
9-1616
17-3232
33+64

The power-of-2 constraint is critical for the hashing strategy.

Jobs are assigned to shards using FNV-1a hash on the queue name:

function fnv1a(str: string): number {
let hash = 0x811c9dc5; // FNV offset basis
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i);
hash = Math.imul(hash, 0x01000193); // FNV prime, 32-bit multiply
}
return hash >>> 0; // Ensure unsigned
}
const SHARD_MASK = SHARD_COUNT - 1;
function shardIndex(queueName: string): number {
return fnv1a(queueName) & SHARD_MASK;
}

Because SHARD_COUNT is always a power of 2, we use bitwise AND instead of modulo. This is a single CPU instruction vs. an expensive division:

// Fast: single AND instruction
const idx = hash & SHARD_MASK;
// Slow: division + remainder
const idx = hash % SHARD_COUNT;

Each shard manages multiple named queues, each backed by an IndexedPriorityQueue, plus per-queue state handled by composed managers (DLQ, deduplication, rate limiting, dependencies, temporal index):

class Shard {
/** Priority queues by queue name */
readonly queues = new Map<string, IndexedPriorityQueue>();
/** Composed managers for per-queue state */
private readonly uniqueKeyManager = new UniqueKeyManager(); // dedup with TTL
private readonly dlqManager: DlqShard; // dead letter queue
private readonly limiterManager = new LimiterManager(); // rate + concurrency
private readonly dependencyTracker = new DependencyTracker();
private readonly temporalManager = new TemporalManager(); // delayed jobs
getQueue(name: string): IndexedPriorityQueue {
let queue = this.queues.get(name);
if (!queue) {
queue = new IndexedPriorityQueue();
this.queues.set(name, queue);
}
return queue;
}
}

Active jobs (being processed by workers) are tracked in a separate set of processing shards with their own locking:

// Processing shards use job ID hashing (not queue name)
function processingShardIndex(jobId: string): number {
return fnv1a(jobId) & SHARD_MASK;
}

This separation is intentional. The lock hierarchy is strictly enforced:

  1. jobIndex (read) -> 2. completedJobs (read) -> 3. shards[N] (write) -> 4. processingShards[N] (write)

Violating this order would create deadlocks.

Sharding delivers measurable throughput improvements on multi-core systems:

ScenarioSingle Shard4 Shards16 Shards
1 worker, 1 queuebaseline~same~same
4 workers, 4 queuescontention~3.5x~3.8x
16 workers, 16 queueshigh contention~8x~14x

The gains come from reduced lock contention. With a single queue, all workers compete for one lock. With N shards, workers on different queues never contend.

Queue-name sharding, not job-ID sharding. All jobs for a queue live in one shard. This means a single queue can’t benefit from sharding, but it dramatically simplifies the pull operation (no need to check multiple shards).

Separate processing shards. Active jobs are tracked separately from queued jobs. This prevents pull operations from blocking ack/fail operations.

Static shard count. Shards are created at startup and never change. This avoids the complexity of resharding and makes the hash-to-shard mapping immutable.