Runlane
Reference

Postgres Polling Lane

Production Postgres-only lane for durable workers that poll storage directly.

@runlane/lane-postgres-polling is the prescribed production lane when you want Runlane backed by Postgres only. It composes postgresStorage() from @runlane/postgres-storage with createLane() from @runlane/contracts and no external transport.

Use it for ordinary worker-process deployments on EC2, ECS, Fargate, Kubernetes, systemd, or any platform where a long-running process can poll Postgres. Core still owns task execution, schedules, retries, releases, cancellation, workers, and operator APIs. Postgres owns durable truth.

pnpm add @runlane/core @runlane/lane-postgres-polling @runlane/postgres-storage zod

When To Use This Lane

Use this lane when one operational dependency is the right trade-off:

Use it forWhy
Postgres-only production jobsRuns, history, leases, schedules, idempotency, singleton ownership, operator reads, and pruning all live in Postgres.
Long-running worker processesWorkers call runlane.worker({ mode: WorkerMode.Poll }) and claim due runs from storage.
Scheduled jobs and polling jobstick() materializes schedules, makes due retries and releases visible to polling workers, and recovers expired leases.

Use @runlane/lane-local for in-memory development and tests. See Lanes, Storage, And Transport when you are choosing between first-party lanes.

Create The Lane

import { createRunlane, queue, task, WorkerMode } from '@runlane/core'
import { postgresPollingLane } from '@runlane/lane-postgres-polling'
import * as z from 'zod'

const quickbooksQueue = queue({ name: 'quickbooks', default: true })

const syncQuickBooksInvoices = task({
  id: 'quickbooks.invoices.sync',
  queue: quickbooksQueue,
  schema: z.object({ userId: z.string().min(1) }),
  singletonKey: (payload) => `quickbooks_invoices_${payload.userId}`,
  async run(payload, context) {
    await syncInvoicesForUser(payload.userId, { signal: context.signal })
  },
})

const lane = postgresPollingLane({
  connectionString: process.env.DATABASE_URL,
  schema: 'runlane',
})

const runlane = createRunlane({
  lane,
  queues: [quickbooksQueue],
  tasks: { syncQuickBooksInvoices },
})

const worker = runlane.worker({
  mode: WorkerMode.Poll,
  queues: [quickbooksQueue],
  concurrency: 4,
})

process.once('SIGTERM', () => {
  void worker.stop()
})

await worker.closed

postgresPollingLane() accepts the same Postgres storage options users learn for postgresStorage(), plus an optional lane diagnostic name:

OptionRequiredDefaultWhat it controls
connectionStringYesNoneForwarded to postgresStorage(). Must use postgres:// or postgresql://.
schemaNoURL ?schema=, then publicForwarded to postgresStorage(). Must be an unquoted Postgres identifier.
nameNopostgres-pollingLane diagnostic name.

Run getPostgresStorageMigrationSql() from @runlane/postgres-storage in your normal migration system before starting the lane. The polling lane does not auto-migrate. lane.start() probes the migrated Postgres tables and fails fast when the database, schema, or migration is not ready.

Capabilities

postgresPollingLane() returns a standard Lane from createLane():

BoundaryReported behavior
Lane namename ?? 'postgres-polling'
lane.capabilities.operatorReadstrue, backed by Postgres operator reads
lane.capabilities.productionDurabletrue
lane.capabilities.storageExact Postgres storage capabilities: durable state, process-shared state, durable steps, observation export, leases, idempotency, singleton, queue concurrency, schedule occurrence claims, persisted outbox, history reads, and pruning
lane.delivery.modestorage_polling

The polling lane has no external transport. Core does not create delivery outbox rows for this lane, and tick() does not publish provider wakeups. If no polling worker is running, no external provider will wake work; the run remains durable and due in Postgres until a worker starts.

Worker Processes

Run a polling worker anywhere you can keep a process alive:

import { WorkerMode } from '@runlane/core'

const worker = runlane.worker({
  mode: WorkerMode.Poll,
  queues: [quickbooksQueue],
  concurrency: Number(process.env.RUNLANE_WORKER_CONCURRENCY ?? 4),
  emptyPollDelay: '100ms',
  leaseDuration: '5m',
})

process.once('SIGTERM', () => {
  void worker.stop()
})

process.once('SIGINT', () => {
  void worker.stop()
})

await worker.closed

concurrency is local process fan-out. Queue concurrencyLimit is durable Postgres policy across all polling workers. Multiple worker processes can run for the same queue; storage decides which run each process owns through leases.

Maintenance

Polling workers execute due runs, but they do not materialize schedules or recover every waiting state on their own. Run tick() from a separate maintenance loop:

import { setTimeout as sleep } from 'node:timers/promises'

while (!signal.aborted) {
  await runlane.tick()
  await sleep(30_000, undefined, { signal })
}

One maintenance pass handles:

Tick phaseWhy it matters in Postgres polling
SchedulesDue task-colocated schedules become durable queued runs.
Retries and releasesRuns waiting for retry backoff or ctx.release() are re-queued when due.
Cancellation finalizationAbandoned cancellation requests become terminal when eligible.
Delivery recoveryDue waiting or expired leased runs get fresh delivery intent so polling workers can claim them.
Expired leasesCrashed workers become recoverable after their lease expires.
Bounded queue dispatchPostgres reserves durable queue capacity before dispatching bounded-queue runs.

A common starting cadence is 30 seconds. Use a shorter cadence when schedule or retry latency matters; use a longer cadence when database polling pressure matters more. Worker emptyPollDelay controls how often idle workers scan for due runs after maintenance has made them runnable.

Deployment Shapes

Use one shared runtime factory for producers, workers, maintenance, and operator commands:

RoleWhat it does
ProducerCalls runlane.start() once per process, then runlane.trigger() from app code.
WorkerRuns runlane.worker({ mode: WorkerMode.Poll }) for one or more queues.
MaintenanceCalls runlane.tick() on a cadence for schedules, retries, releases, lease recovery, and cancellation finalization.
Operator/CLILists, inspects, cancels, retries, reruns, prunes, or triggers runs against the same Postgres lane.

For ECS, Fargate, Kubernetes, or systemd, run the worker and maintenance roles as separate process types when possible. A small deployment can run both loops in one process, but keep the code paths separate so worker failures and maintenance failures are visible.

On this page