For the complete documentation index, see llms.txt.
Skip to main content
Version: 8.10 (unreleased)

Job Workers

Job workers subscribe to a specific job type and process jobs as they become available. The worker handles polling, concurrent dispatch, auto-completion, and error handling.

Basic Worker​

using Camunda.Orchestration.Sdk;

// Define input/output DTOs
public record OrderOutput(bool Processed, string InvoiceNumber);

using var client = CamundaClient.Create();

client.CreateJobWorker(
new JobWorkerConfig
{
JobType = "process-order",
JobTimeoutMs = 30_000,
},
async (job, ct) =>
{
var input = job.GetVariables<OrderInput>();
var invoice = await ProcessOrder(input!, ct);

// Return value auto-completes the job with these output variables
return new OrderOutput(true, invoice);
});

// Block until Ctrl+C
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
await client.RunWorkersAsync(ct: cts.Token);

Handler Contract​

The handler return value determines the job outcome:

Handler behaviorJob outcome
Return objectAuto-complete with those variables
Return nullAuto-complete with no variables
Return JobCompletionRequestComplete with structured result (corrections, denial)
Throw BpmnErrorExceptionTrigger a BPMN error boundary event
Throw JobFailureExceptionFail with custom retries / back-off
Throw any other exceptionAuto-fail with retries - 1
// BPMN error — caught by error boundary events in the process model
throw new BpmnErrorException("INVALID_ORDER", "Order not found");

// Explicit failure with retry control
throw new JobFailureException("Service unavailable", retries: 2, retryBackOffMs: 5000);

Job Corrections (User Task Listeners)​

When handling jobs from user task listeners, you can return a JobCompletionRequest to apply corrections to the task or deny the action. Return a JobCompletionRequest from the handler instead of a plain variables object:

client.CreateJobWorker(config, async (job, ct) =>
{
// Apply corrections to the user task
return new JobCompletionRequest
{
Variables = new { reviewed = true },
Result = new JobResultUserTask
{
Corrections = new JobResultCorrections
{
Assignee = "new-assignee",
Priority = 75,
CandidateGroups = new List<string> { "managers" },
},
},
};
});

To deny the user task action (e.g. reject a completion):

client.CreateJobWorker(config, async (job, ct) =>
{
return new JobCompletionRequest
{
Result = new JobResultUserTask
{
Denied = true,
DeniedReason = "Missing required fields",
},
};
});

Void Handler (No Output Variables)​

For handlers that don't return output variables, use the void overload:

public record NotificationInput(string Message);

client.CreateJobWorker(config, async (job, ct) =>
{
await SendNotification(job.GetVariables<NotificationInput>()!, ct);
// Auto-completes with no variables
});

Configuration​

PropertyDefaultDescription
JobType(required)BPMN task type to subscribe to
JobTimeoutMs(env / required)Job lock duration (ms). Falls back to CAMUNDA_WORKER_TIMEOUT env var.
MaxConcurrentJobs10Max in-flight jobs per worker. Falls back to CAMUNDA_WORKER_MAX_CONCURRENT_JOBS env var, then 10.
PollIntervalMs500Delay between polls when idle
PollTimeoutMsnullLong-poll timeout (null = broker default). Falls back to CAMUNDA_WORKER_REQUEST_TIMEOUT env var.
FetchVariablesnullVariable names to fetch (null = all)
WorkerNameautoWorker name for logging. Falls back to CAMUNDA_WORKER_NAME env var.
AutoStarttrueStart polling on creation
StartupJitterMaxSeconds0Max random delay (seconds) before first poll. Falls back to CAMUNDA_WORKER_STARTUP_JITTER_MAX_SECONDS env var.
TenantIdsnullTenant IDs to activate jobs for. Falls back to CAMUNDA_TENANT_IDS, then [CAMUNDA_DEFAULT_TENANT_ID]. Mutually exclusive with TenantId.
TenantIdnullSingle-tenant convenience for TenantIds. Mutually exclusive with TenantIds.
TenantFilternullTenant filtering strategy (PROVIDED / ASSIGNED). See Multi-Tenant Workers. Requires Camunda 8.9+.

Multi-Tenant Workers​

A worker activates jobs for a fixed tenant set (TenantIds / TenantId), or delegates the choice to the server with TenantFilter:

TenantFilterBehavior
null / PROVIDEDActivate jobs for the tenants named in TenantIds / TenantId, falling back to CAMUNDA_TENANT_IDS, then the default tenant.
ASSIGNEDActivate jobs for whichever tenants are currently assigned to the authenticated client. No tenant IDs are sent.

The fixed tenant set can also come from configuration, so a fleet of workers shares one tenant list:

export CAMUNDA_TENANT_IDS=acme,globex
// appsettings.json — array or comma-separated string
{
"Camunda": {
"TenantIds": ["acme", "globex"]
}
}

Tenant precedence (highest wins): JobWorkerConfig.TenantIds / TenantId > CAMUNDA_TENANT_IDS > [CAMUNDA_DEFAULT_TENANT_ID]. An empty TenantIds list counts as unset. TenantFilter = ASSIGNED opts out of the chain entirely — the configured tenant IDs are ignored, not sent.

// Fixed tenant set — activate jobs for these tenants only
client.CreateJobWorker(
new JobWorkerConfig
{
JobType = "process-order",
JobTimeoutMs = 30_000,
TenantIds = new[] { "acme", "globex" },
},
async (job, ct) => null);

// Dynamic tenant set — activate jobs for whichever tenants are currently
// assigned to the authenticated client. The server re-evaluates the
// assignment on every activation request, so tenants added or removed in
// Camunda take effect without restarting the worker.
client.CreateJobWorker(
new JobWorkerConfig
{
JobType = "process-order",
JobTimeoutMs = 30_000,
TenantFilter = TenantFilterEnum.ASSIGNED,
},
async (job, ct) => null);

ASSIGNED re-reads the assignment on every activation request, so the worker picks up newly assigned tenants and drops removed ones without restarting and without the application querying or caching the tenant list. Notes:

  • ASSIGNED cannot be combined with TenantIds or TenantId — the server would ignore them, so CreateJobWorker throws ArgumentException instead.
  • ASSIGNED requires multi-tenancy to be enabled on the cluster; otherwise the activation request is rejected with HTTP 400.

Heritable Worker Defaults​

When running many workers with the same base configuration, you can set global defaults via environment variables. These apply to every worker created by the client unless the individual JobWorkerConfig explicitly overrides them.

Environment VariableConfig PropertyType
CAMUNDA_WORKER_TIMEOUTJobTimeoutMslong
CAMUNDA_WORKER_MAX_CONCURRENT_JOBSMaxConcurrentJobsint
CAMUNDA_WORKER_REQUEST_TIMEOUTPollTimeoutMslong
CAMUNDA_WORKER_NAMEWorkerNamestring
CAMUNDA_WORKER_STARTUP_JITTER_MAX_SECONDSStartupJitterMaxSecondsint

Precedence: explicit JobWorkerConfig value > environment variable > hardcoded default.

export CAMUNDA_WORKER_TIMEOUT=30000
export CAMUNDA_WORKER_MAX_CONCURRENT_JOBS=8
export CAMUNDA_WORKER_NAME=order-service
// Workers inherit timeout, concurrency, and name from environment
client.CreateJobWorker(
new JobWorkerConfig { JobType = "validate-order" },
async (job, ct) => null);

client.CreateJobWorker(
new JobWorkerConfig { JobType = "ship-order" },
async (job, ct) => null);

// Per-worker override: this worker uses 32 concurrent jobs instead of the global 8
client.CreateJobWorker(
new JobWorkerConfig { JobType = "bulk-import", MaxConcurrentJobs = 32 },
async (job, ct) => null);

You can also pass defaults programmatically via the client constructor:

var client = CamundaClient.Create(new CamundaOptions
{
Config = new Dictionary<string, string>
{
["CAMUNDA_WORKER_TIMEOUT"] = "30000",
["CAMUNDA_WORKER_MAX_CONCURRENT_JOBS"] = "8",
},
});

Concurrency​

Jobs are dispatched as concurrent Tasks on the .NET thread pool. MaxConcurrentJobs controls how many jobs may be in-flight simultaneously.

  • I/O-bound handlers (HTTP calls, database queries): higher values like 32–128 improve throughput because async handlers release threads during await points — many jobs, few OS threads.
  • CPU-bound handlers: set MaxConcurrentJobs to Environment.ProcessorCount to match cores.
  • Sequential processing: set MaxConcurrentJobs = 1.

Lifecycle​

// Manual start/stop
var worker = client.CreateJobWorker(new JobWorkerConfig { JobType = "example", JobTimeoutMs = 30_000, AutoStart = false }, handler);
worker.Start();

// Graceful stop — waits up to 10s for in-flight jobs to finish
var result = await worker.StopAsync(gracePeriod: TimeSpan.FromSeconds(10));
// result.RemainingJobs, result.TimedOut

// Or stop all workers at once
await client.StopAllWorkersAsync(TimeSpan.FromSeconds(10));

// DisposeAsync stops workers automatically
await using var disposableClient = CamundaClient.Create();