Deploy with Postgres polling
Keep runs in Postgres and let long-running workers poll for work.
Choose this lane when your workers can connect directly to Postgres and you want the smallest durable production setup.
Deployment shape
producer -- writes runs ----------> Postgres
worker -- polls and claims -----> Postgres
Postgres -- best-effort signal ---> worker
maintenance -- advances due work ----> PostgresPostgres stores the truth. Workers claim work there directly. The signal helps an idle worker wake sooner, but losing a signal does not lose the run.
Install the packages
npm install @runlane/core @runlane/lane-postgres-polling @runlane/postgres-storage zodThe files below use one practical layout:
src/
├── tasks/send-email.ts
├── runlane.ts
├── worker.ts
└── maintenance.ts
scripts/
└── migrate-runlane.tsRename the paths to fit your app, but keep the worker and maintenance entry points out of request handlers.
Put task code in its own module
Create src/tasks/send-email.ts. Producers import the task definition for its schema and id. Only workers execute run() and therefore need EMAIL_API_URL.
import { task } from '@runlane/core'
import * as z from 'zod'
const envSchema = z.object({ EMAIL_API_URL: z.url() })
export const sendEmail = task({
id: 'email.send',
schema: z.object({ to: z.email() }),
async run({ to }, context) {
const { EMAIL_API_URL } = envSchema.parse(process.env)
const response = await fetch(EMAIL_API_URL, {
body: JSON.stringify({ to }),
headers: { 'content-type': 'application/json' },
method: 'POST',
signal: context.signal,
})
if (!response.ok) throw new Error(`Email API returned ${response.status}`)
},
})Create one runtime module
Create src/runlane.ts. Producers, workers, and maintenance import this module so they use the same environment, task catalog, queue definition, and Postgres lane.
Set DATABASE_URL before this module loads:
import { createRunlane, queue } from '@runlane/core'
import { postgresPollingLane } from '@runlane/lane-postgres-polling'
import * as z from 'zod'
import { sendEmail } from './tasks/send-email.js'
const { DATABASE_URL } = z.object({ DATABASE_URL: z.string().min(1) }).parse(process.env)
export const emailQueue = queue({ name: 'email', default: true })
export const runlane = createRunlane({
lane: postgresPollingLane({ connectionString: DATABASE_URL, schema: 'runlane' }),
queues: [emailQueue],
tasks: { sendEmail },
})Apply migrations before startup
Create scripts/migrate-runlane.ts and run it as a release or deployment step before the new application starts. Do not call it from a request handler or worker startup.
import { applyPostgresStorageMigrations } from '@runlane/postgres-storage'
import * as z from 'zod'
const { DATABASE_URL } = z.object({ DATABASE_URL: z.string().min(1) }).parse(process.env)
await applyPostgresStorageMigrations({ connectionString: DATABASE_URL, schema: 'runlane' })Apply and validate migrations before starting the new app version. Runtime startup does not inspect or change the schema. The package migration runner holds a schema-scoped advisory lock, so concurrent deploy jobs settle on one owner.
The package installs schema version 1 through one initial migration containing the current records, slim operator projections, retained run payloads, and atomic-write assertion function. Apply it to a fresh schema. Runlane 0.4 intentionally replaces the schema shipped with 0.3; a 0.3 database cannot be upgraded in place, and the migration runner rejects its ledger.
The Postgres storage driver documents the migration contract, physical records, and rollback policy.
Start a worker
Create src/worker.ts and use it as the worker service's process entry point:
import { emailQueue, runlane } from './runlane.js'
const worker = await runlane.createDeliveryWorker({
concurrency: 8,
queues: [emailQueue.name],
})
const close = () => void worker.close()
process.once('SIGTERM', close)
try {
await worker.waitUntilClosed()
} finally {
process.off('SIGTERM', close)
await worker.close()
await runlane.close()
}The entry point handles cooperative SIGTERM shutdown. Your process supervisor should restart it when waitUntilClosed() rejects.
The runtime automatically batches independent lifecycle writes and concurrent point reads. Handler concurrency still controls how many tasks can execute at once; it does not require the same number of simultaneous database transactions. Unbounded queues can also share acquisition batches. Bounded queue capacity remains enforced in storage, and worker shutdown waits for pending lifecycle writes.
Run maintenance separately
Create src/maintenance.ts and run it as a separate supervised service:
import { runlane } from './runlane.js'
await runlane.start()
const services = runlane.startServices({
onServiceError(error, { phase }) {
process.stderr.write(`${phase}: ${error.message}\n`)
},
})
const close = () => void services.close()
process.once('SIGTERM', close)
try {
await services.waitUntilClosed()
} finally {
process.off('SIGTERM', close)
await services.close()
await runlane.close()
}Maintenance owns schedules, due retries, waits, token timeouts, expired attempts, cancellation cleanup, delivery recovery, and outbox publishing. See run maintenance for bounded scheduled calls and operational checks.
Check the deployment
In an isolated environment:
- Confirm all migrations from the installed provider version have been applied.
- Start
src/worker.tsandsrc/maintenance.tsas separate processes. - Trigger one task from an application process that imports
runlanefromsrc/runlane.ts. - Confirm the worker stores the terminal result.
- Complete a token-backed wait and confirm maintenance makes the task runnable again.
- Stop a worker during a leased attempt and confirm lease recovery works.
A missing or incompatible schema fails on the first operation that needs it. Fix the deployment migration step rather than handling that failure in application code.
If storage is healthy but a run stays queued, check the worker's queue filter, environment, and task catalog. Use process supervision for repeated database failures.
Deploy compatible task catalogs
A polling worker claims by logical queue before it resolves the run's task id. Every worker polling a queue must therefore register every task that producers can create in that queue. An older worker that claims an unknown task records a terminal, non-retryable TaskNotFound failure for that run.
Before a producer creates a task on an existing queue, upgrade or stop every worker polling that queue. To let old and new workers overlap, give the new task version a new task id and a distinct logical queue, then configure only new workers to poll that queue. A new queue name isolates work only when old workers do not register or explicitly poll it.
Keep old task definitions and their queues available until queued, retrying, released, and waiting runs have drained. If a task id remains unchanged, its handler and saved payload, task-output, and step-output shapes must remain compatible with runs created by the previous release. Upgrade the maintenance owner before enabling schedules that exist only in the new task catalog.
Next, give maintenance a production owner.