Skip to content

Workers & scaling

Every process that calls AddPgWorkflows is a worker. Call DisableWorkers() and it’s a client. That’s the whole model.

  • A worker leases and executes workflows and activities.
  • A client starts, signals, and awaits workflows, and never executes anything.

Postgres is the only coordination layer. There is no scheduler service, leader election, or message broker.

There is no worker setup. AddPgWorkflows registers a hosted background worker, so the app that defines your workflows processes them. An ASP.NET API, a console app, and a Windows service are all equally valid workers.

builder.Services.AddPgWorkflows(pg =>
pg.UsePostgres(connectionString)
.AddWorkflow<TrialOnboardingWorkflow>()
.AddActivities<EmailActivities>()
);

To scale, deploy more instances. Postgres coordinates them:

  • Short FOR UPDATE SKIP LOCKED transactions let workers claim different rows without blocking.
  • Leases are heartbeated while work runs, so healthy slow work is not normally reclaimed.
  • A dead worker’s lease expires; a peer resumes the run from the last recorded step.
  • A worker that lost its lease has stale database writes rejected by its token.

Only one worker has the current valid lease, but activity execution is at least once. A frozen worker can continue an external side effect after its lease is reclaimed, and a crash between a side effect and recording its result causes another execution. Make activities idempotent.

A front-facing API shouldn’t compete for work. It should dispatch and move on.

// API: pure client, runs no workers
builder.Services.AddPgWorkflows(pg =>
pg.UsePostgres(connectionString)
.DisableWorkers()
.AddWorkflow<TrialOnboardingWorkflow>()
);

Starting a workflow is a single INSERT. Whichever worker leases the run first executes it. Benchmark the path against your own database and workload before treating it as suitable for a high-throughput endpoint.

For high-throughput APIs, prefer fire-and-forget: StartAsync, return the run id, let callers check back. Awaiting GetResultAsync per request polls the database; prefer asynchronous status endpoints or callbacks when many callers may wait concurrently.

Postgres is the system of record and coordinator. If it is unavailable, new workflow starts and signals cannot be durably accepted and should fail back to the caller rather than being acknowledged into an in-memory queue. Workers back off when store operations fail; after recovery, unfinished work is reclaimed through its persisted state and expired leases. Work around the outage may execute again, so normal activity idempotency rules still apply.

Use a highly available Postgres deployment when the workflow control plane is production-critical. Caller retries should use workflow and signal idempotency keys.

Put workflows and activities in a shared class library; every participating process registers from it:

MyApp.Workflows/ ← workflow + activity classes
MyApp.Api/ ← client: AddWorkflow + DisableWorkers
MyApp.Worker/ ← worker: AddWorkflow + AddActivities

Clients need AddWorkflow (to resolve names and types) but can skip AddActivities, since activities only matter where they execute.

All knobs and defaults are in the configuration reference. Three worth knowing early:

  • WorkerId defaults to the machine name; set it explicitly in containers so leases are attributable when debugging.
  • MaxConcurrency defaults to 10 per worker kind; raise it for IO-heavy fleets, lower it for CPU-bound work.
  • Each process holds its own connection pool, sized automatically to fit its worker concurrency (a client-only process gets 20). The sum across all API and worker processes must stay below Postgres’ max_connections; see connection pooling.