Runlane
Introduction

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

NeedRunlane primitiveFirst page
Run work outside a request pathtask() + trigger()Tasks And Runs
Start one durable attempt in the current processrunNow()Current-Process Execution
Keep one resource from overlappingsingletonKeyIdentifiers
Collapse producer retriesidempotencyKeyTasks And Runs
Wait for external state without failure noisecontext.release()Retry Vs Release
Run work on a cadencetask-colocated scheduleSchedules
Execute locallycreateLocalLane() + executeNext()Quick Start
Execute from SQSexecuteDelivery(message)SQS Transport
Inspect and recover production workrunlane.runs.* and runlane runs, retry, rerun, cancel, pruneOperator 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 zod

Define 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.closed

Use 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.

On this page