Runlane
Durable tasks, schedules, queues, and wakeups for TypeScript applications.
Runlane helps a TypeScript app run async work durably without hiding the work in a separate platform. You define tasks in code, trigger runs from your app, and let storage preserve the state that workers need after deploys, crashes, retries, and operator actions.
Durable tasks, schedules, queues, and wakeups that stay on your infrastructure. Contracts define the durable shapes, core owns behavior, and storage and transport adapters stay replaceable.
Runlane is for work that must outlive the request that created it: welcome emails, account syncs, provider polling, media processing, scheduled maintenance, and operator-controlled recovery.
The model is intentionally small. A task is user code plus a payload schema. A run is the durable record of one execution. Storage owns truth. Transport only wakes workers.
What You Build With It
| Need | Runlane primitive | First page |
|---|---|---|
| Run work outside a request path | task() + trigger() | Tasks And Runs |
| Start one durable attempt in the current process | runNow() | Current-Process Execution |
| Keep one resource from overlapping | singletonKey | Identifiers |
| Collapse producer retries | idempotencyKey | Tasks And Runs |
| Wait for external state without failure noise | context.release() | Retry Vs Release |
| Run work on a cadence | task-colocated schedule | Schedules |
| Execute locally | createLocalLane() + executeNext() | Quick Start |
| Execute from SQS | executeDelivery(message) | SQS Transport |
| Inspect and recover production work | runlane.runs.* and runlane runs, retry, rerun, cancel, prune | Operator APIs |
10-Minute Tutorial
Start with the local lane. It keeps Runlane state in memory for the current process, which makes it useful for development, tests, and examples.
pnpm add @runlane/core @runlane/lane-local zodDefine one queue and one task:
import { queue, task } from '@runlane/core'
import * as z from 'zod'
const emailQueue = queue({ name: 'emails', default: true })
const sendWelcomeEmail = task({
id: 'emails.welcome',
queue: emailQueue,
schema: z.object({ userId: z.string().min(1) }),
idempotencyKey: (payload) => `emails.welcome.${payload.userId}`,
async run(payload, context) {
// Replace this with your email provider call.
await emailProvider.sendWelcome(payload.userId, { signal: context.signal })
},
})Create a local runtime:
import { createRunlane } from '@runlane/core'
import { createLocalLane } from '@runlane/lane-local'
const runlane = createRunlane({
lane: createLocalLane(),
queues: [emailQueue],
tasks: { sendWelcomeEmail },
})Trigger durable work. trigger() validates the payload, creates the run, records a delivery request, and by default tries to publish that wakeup immediately:
const { run } = await runlane.trigger(runlane.tasks.sendWelcomeEmail, {
userId: 'user_123',
})Execute one due run locally:
const completed = await runlane.executeNext()Use a drain worker when a script or test should process currently due work and exit:
import { WorkerMode } from '@runlane/core'
const worker = runlane.worker({ mode: WorkerMode.Drain })
await worker.closedUse runNow() when the caller should create a durable run and execute its first attempt inline, without waiting for a worker or transport wakeup:
const completedInline = await runlane.runNow(runlane.tasks.sendWelcomeEmail, {
userId: 'user_123',
})Add business waiting without recording a failure. A released run becomes due again later; maintenance records a fresh delivery request when it is time to continue:
const pollReport = task({
id: 'reports.poll',
schema: z.object({ reportId: z.string().min(1) }),
async run(payload, context) {
const report = await reports.get(payload.reportId)
if (report.status === 'processing') {
return context.release('30s', { reason: 'provider_not_ready' })
}
await saveReport(report)
},
})Run maintenance from a cron, scheduled function, container sidecar, or local development loop:
await runlane.tick()One maintenance pass can:
- materialize due schedules
- wake released or retried runs
- recover expired leases
- finalize abandoned cancellations
- flush outbox rows
For production SQS delivery, use the Postgres/SQS lane and an SQS consumer. The SQS message carries only a wakeup; the handler calls executeDelivery(message) so core can re-read storage, claim a lease, and ignore stale or duplicate wakeups safely.
import { createRunlane } from '@runlane/core'
import { postgresSqsLane } from '@runlane/lane-postgres-sqs'
const runlane = createRunlane({
lane: postgresSqsLane({ postgres, sqs }),
queues: [emailQueue],
tasks: { sendWelcomeEmail },
})
await runlane.executeDelivery(message)Keep tick() separate from SQS record handling. Delivery consumers should handle the wakeups they received; maintenance handles schedules, retries, releases, lease recovery, cancellation cleanup, and outbox recovery.
Where to start
- Concepts — learn the durable primitives.
- Quick Start — run one local task end to end.
- Reference — read the TypeScript contracts.
- Configuration — understand lane, storage, and transport capabilities.