Limits & Defaults

The named storage bounds — payload size, wire-name length, parent count, output bytes, batch sizes, plus the retry and lease defaults — each with its default value and the reject-not-truncate rule.


Every job store enforces a small set of named size and batch limits so a single misbehaving enqueue or an oversized result cannot corrupt the store or starve a Worker. These limits live on StoreBounds, a record with one default value per limit, and a store configured without explicit bounds uses StoreBounds.Default. The governing rule is that limits are enforced with clear errors rather than silent truncation, with one deliberate exception for write-only diagnostics. Two related defaults, the retry attempt ceiling and the Worker Lease duration, are not storage bounds, but they are collected here so every default sits in one place.

The storage bounds#

StoreBounds names each limit, its default, and what happens when a request exceeds it. The behavior column is the important part: some limits reject the offending write, some clamp an over-large request down to the cap, and some age or truncate unbounded-growth data.

BoundDefaultUnitOver-limit behavior
MaxPayloadBytes65,536bytesRejected. The enqueue fails; nothing is created.
MaxWireNameLength128charactersRejected. The enqueue fails; nothing is created.
MaxParentsPerJob16parentsRejected. The enqueue fails; nothing is created.
MaxOutputBytes65,536bytesRejected. The outcome write fails loudly.
MaxFailureDetailBytes8,192bytesTruncated (the one exception), on a code-point boundary.
MaxClaimBatch32jobsClamped down. A larger claim request is capped at this.
MaxPurgeBatch500jobsClamped down. A larger purge request is capped at this.
MaxMonitorPageSize200rowsClamped down. A larger page request is capped at this.
MaxRecordedSkippedTicks32ticksAges out. The oldest recorded skips drop as new ones arrive.
MaxTransitionsPerJob64entriesAges out. The oldest transition drops when the cap would be exceeded.

The Wire Name limit is measured in characters, counted as UTF-16 code units, so a name built from characters outside the Basic Multilingual Plane reaches the cap in fewer visible characters than its length in code units suggests.

Reject, clamp, or truncate#

Which of the three behaviors applies to a limit follows from what the data is used for.

  • Size caps on data that is later read back and deserialized are rejected. Payload, Wire Name, Job Output, and the parent set are all functional inputs. A clipped payload or a clipped output blob cannot be deserialized, so silently truncating it would hand a downstream reader a corrupted value. Rejecting the write loudly is safer than corrupting the reader, so an over-limit enqueue or outcome fails and the store is left untouched.
  • Caps on batch and paging requests are clamped down silently. Asking to claim, purge, or page more rows than the cap allows is not an error; the request is simply served up to the cap. A claim for 100 jobs against a MaxClaimBatch of 32 returns at most 32.
  • Caps on write-only diagnostics and unbounded-growth history are aged or truncated. Failure Detail is never a semantic input, so it is truncated rather than refused. Transition history and recorded skipped ticks are bounded by dropping the oldest entries.

Payload and Wire Name#

An enqueue is checked against three bounds before the job is created: the serialized payload size, the Wire Name length, and the number of declared parents. If any check fails, no job is created. The parent set is de-duplicated before its count is checked, so declaring the same parent twice does not count twice against MaxParentsPerJob.

At the client surface, an over-limit payload surfaces as an ArgumentException naming the Wire Name and the actual byte count, with the guidance to store a reference rather than the data itself.

EnqueueLargePayload.cs
// If the serialized OrderCharged payload exceeds MaxPayloadBytes,
// EnqueueAsync throws ArgumentException rather than creating the job.
await client.EnqueueAsync(new OrderCharged(orderId), DateTimeOffset.UtcNow);

The remedy for a payload that is too large is to store the bulk data somewhere addressable and enqueue only a reference to it, an id or a blob key, keeping the job's payload small.

Job Output#

A handler may emit one Job Output, an opaque blob persisted only when the job succeeds. Every other outcome, including a graceful failure, writes no output. When output is present on a successful outcome, it is checked against MaxOutputBytes; an over-limit blob is rejected and the outcome write throws JobOutputTooLargeException. The check runs before the outcome is committed, so an over-limit write leaves the store untouched.

JobOutputTooLargeException carries the job id, the actual byte count, and the configured MaxOutputBytes:

MemberTypeMeaning
JobIdGuidThe job whose output was rejected.
ActualBytesintThe size of the output blob that was refused.
MaxOutputBytesintThe configured output cap it exceeded.

Output is rejected rather than truncated for the same reason as payload: a dependent descendant deserializes it, and a clipped serialized blob is undeserializable. The default, 65,536 bytes, matches the payload cap, the natural size class for one job's result, but the two limits are independent and can be set separately. As with an oversized payload, the fix is to persist the large result externally and emit a reference to it as the output.

Failure Detail#

Failure Detail is the write-only diagnostics captured on a failing attempt: the exception type, message, and stack. The Core never reads it back, so it is the single exception to the reject-not-truncate rule. When captured detail exceeds MaxFailureDetailBytes, it is truncated to fit rather than refused. The truncation is byte-exact and never splits a UTF-8 code point, so the stored detail always round-trips as valid text; a null or already-short detail passes through untouched.

Batch and page sizes#

Three limits cap how much work a single request moves, and all three clamp rather than reject. A request larger than the cap is served up to the cap and no error is raised.

BoundDefaultApplies to
MaxClaimBatch32The most jobs a single claim returns.
MaxPurgeBatch500The most jobs a single purge removes.
MaxMonitorPageSize200The most rows a monitor listing returns per page.

Configuring the bounds#

StoreBounds is a record with an init property per limit and a static Default accessor. A store configured without explicit bounds uses StoreBounds.Default; to override a limit, construct a StoreBounds and pass it to the store. Because it is a record, use a with expression to change one bound and keep the rest at their defaults.

ConfigureBounds.cs
// Start from the defaults and raise only the payload and output caps.
var bounds = StoreBounds.Default with
{
    MaxPayloadBytes = 131_072,
    MaxOutputBytes = 131_072,
};
 
var store = new InMemoryJobStore(bounds);

Attempts and retries#

The number of times a job is tried before it dead-letters is not a storage bound. It is a retry-policy value: RetryPolicy.MaxAttempts, which defaults to 10. Attempts are 1-based — the first attempt is attempt 1 — and a claim increments the attempt counter, so a Lease that expires mid-execution counts as an attempt exactly like a thrown exception does. When a job has failed its final allowed attempt, it dead-letters instead of retrying.

RetryPolicy also carries the backoff schedule that spaces out retries. The default backoff is exponential, two raised to the attempt number, in seconds, capped at five minutes.

MemberTypeDefault
MaxAttemptsint10
BackoffFunc<int, TimeSpan>Exponential, 2^attempt seconds, capped at 300s.
ConfigureRetries.cs
// Fewer attempts, keeping the default exponential backoff.
var policy = RetryPolicy.Default with { MaxAttempts = 5 };

A Job type can override the group policy with the [Retry] attribute. That override has two bounds of its own. The source generator validates both at compile time.

BoundValueApplies to
Attempt ceiling1 to 1000The first argument to [Retry].
Backoff intervals1 to 20 valuesThe interval list, which never accepts a negative value.

These two bounds reject rather than clamp: a value outside the range is a build error, not a silently corrected one.

The Execution Model page covers how attempts, retries, and dead-lettering fit into a job's lifecycle.

Lease duration#

A Lease is a Worker's time-bounded, heartbeat-renewed claim on a job. Its duration is not a storage bound and not a fixed constant; it is supplied by the Worker on each claim and each heartbeat, and the store extends the job's lease that far past the current time. The default Worker Lease is 60 seconds, renewed by heartbeat while the job runs. When a Lease lapses without a heartbeat, because the Worker crashed or stalled, the job becomes claimable again, which is the mechanism behind at-least-once delivery.

Because the lease duration travels with each claim rather than being stored, a longer-running class of work simply claims with a longer lease; there is no single global lease setting to tune. The Execution Model page covers Leasing, heartbeats, and reclaim in full.

Where to go next#

  • Execution Model: attempts, retries, dead-lettering, Leasing, and heartbeats in the context of a running job.
  • Jobs and Handlers: payloads, Wire Names, and Job Output as part of a job's definition.
  • Dependencies: declaring parents and reading a parent's output, where MaxParentsPerJob applies.
  • Queues: the default Queue name, pool size, and Concurrency Limits, the other side of a deployment's defaults.

Found a problem on this page? Report an issue