Skip to main content

Performance

ZeroAlloc.Scheduling is designed for moderate-to-high-throughput background job workloads where scheduler overhead should be negligible relative to the job work itself. This page covers the allocation profile of the scheduler, key tuning parameters, and guidance on when the design choices matter in practice.

Allocation Profile

The hot path — polling the store, claiming jobs, deserialising payloads, executing handlers — performs the following allocations per job:

StepAllocation
FetchPendingAsync SQL/Redis queryNetwork buffer (store-dependent)
Payload deserialisation (DefaultJobSerializer)Boxed job object (JSON reflection)
IServiceScope creation per job1 managed object
Executor dispatch0 (compile-time static call via generated class)
ValueTask returned by ExecuteAsync0 if synchronous completion

The executor dispatch itself is zero-allocation — the generator emits a concrete class with a direct _serializer.Deserialize<T> call. There is no dictionary lookup, no Type.GetType, no virtual dispatch on the executor.

The serialiser (DefaultJobSerializer) uses System.Text.Json with reflection-based serialisation by default. For AOT scenarios, register a source-generated JsonSerializerContext.

Tuning Parameters

PollingInterval

How often the worker wakes to claim pending jobs. Default: 5 seconds.

services.AddScheduling(opt => opt.PollingInterval = TimeSpan.FromSeconds(2));

Lower values reduce job start latency at the cost of more store queries when the queue is empty. For fire-and-forget jobs that must start quickly, consider lowering to 1–2 seconds.

BatchSize

Maximum jobs claimed per poll cycle. Default: 20.

services.AddScheduling(opt => opt.BatchSize = 50);

Increase for higher throughput at the cost of larger transactions. Each claimed job acquires a lock in the store (an atomic UPDATE for EF Core, a transaction for Redis).

RetryBaseDelay

Base delay for exponential backoff between retries. Default: 2 seconds.

Retry delays follow base * 2^(attempt-1):

AttemptDelay (base = 2s)
1 (first retry)2 s
24 s
38 s
416 s

DefaultMaxAttempts

Global retry limit before a job is dead-lettered. Default: 3.

services.AddScheduling(opt => opt.DefaultMaxAttempts = 5);

Override per job type with [Job(MaxAttempts = N)].

Multiple Workers

The store implementations are safe for concurrent workers. FetchPendingAsync uses an atomic claim pattern:

  • EF Core: ExecuteUpdateAsync with a conditional WHERE Status IN (Pending, Failed) — only rows still unclaimed are updated
  • Redis: ITransaction with a WATCH on the job key

To scale horizontally, run multiple instances of your application. Each instance runs its own SchedulingWorkerService. Jobs are distributed across workers by whichever instance claims them first.

When Scheduler Overhead Matters

Scheduler overhead (polling, claiming, deserialising) is typically 1–10 ms per job depending on the store and network. This is negligible if your jobs take >100 ms each.

Overhead becomes relevant when:

  • Jobs complete in <10 ms (the scheduler adds meaningful relative cost)
  • You enqueue >1,000 jobs/second (polling and claiming become a bottleneck)
  • You run many workers polling a single Redis or SQL instance (connection pressure)

For very high throughput, consider batching work into fewer, larger jobs rather than many small ones.