Runlane
Examples

Local Lane

One-process Runlane example for development and tests.

This example runs Runlane in one Node process with @runlane/lane-local. It uses the same task, run, event, lease, schedule, release, and operator APIs as production lanes, but all state lives in memory, reports processLocalState: true, and disappears when the process exits.

Use this path for first setup, demos, and application tests. Do not use it as a production durability boundary.

Install

pnpm add @runlane/core @runlane/lane-local @runlane/cli zod

@runlane/cli is only needed for the CLI section. The runtime example itself uses @runlane/core, @runlane/lane-local, and your schema package.

Define Work

import { queue, task } from '@runlane/core'
import * as z from 'zod'

const sentEmails: string[] = []
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) {
    sentEmails.push(payload.userId)
    context.signal.throwIfAborted()
  },
})

The idempotency key means duplicate producer calls for the same user return the same active or retained terminal run instead of creating a second welcome-email run.

Create The Runtime

import { createRunlane } from '@runlane/core'
import { createLocalLane } from '@runlane/lane-local'

export function createAppRunlane() {
  return createRunlane({
    lane: createLocalLane(),
    queues: [emailQueue],
    tasks: { sendWelcomeEmail },
  })
}

const runlane = createAppRunlane()

The named task catalog is authoritative. Trigger with runlane.tasks.sendWelcomeEmail so the task handle comes from the same registry workers execute.

Trigger And Drain

import { WorkerMode } from '@runlane/core'

const { run: queuedRun } = await runlane.trigger(runlane.tasks.sendWelcomeEmail, {
  userId: 'user_123',
})

const worker = runlane.worker({ mode: WorkerMode.Drain })
await worker.closed

const completedRun = await runlane.runs.get(queuedRun.id)

Drain mode processes currently due work and exits. It is the easiest worker mode for tests because it does not leave a polling loop running.

Run From The CLI

runlane dev gives local lanes a two-terminal workflow without turning process memory into shared storage. The long-running dev process owns the in-memory lane, starts a polling worker, runs maintenance ticks, and opens a loopback control bridge. Stateful commands in another terminal proxy into that process when they use the same resolved config path.

Create runlane.config.ts next to package.json:

import { type RunlaneCliConfig } from '@runlane/cli'

import { createAppRunlane } from './src/runlane.ts'

export default {
  runtime: createAppRunlane,
} satisfies RunlaneCliConfig
{
  "scripts": {
    "dev": "runlane dev"
  }
}
# Terminal 1
pnpm dev

# Terminal 2
pnpm exec runlane trigger emails.welcome '{"userId":"user_123"}'
pnpm exec runlane runs list
pnpm exec runlane runs get <runId> --events

Restart runlane dev after changing task definitions or runtime wiring. Proxied commands execute against the task catalog already loaded in the dev process.

Add Business Waiting

const reportsSeen = new Set<string>()

const pollReport = task({
  id: 'reports.poll',
  queue: emailQueue,
  schema: z.object({ reportId: z.string().min(1) }),
  async run(payload, context) {
    if (!reportsSeen.has(payload.reportId)) {
      reportsSeen.add(payload.reportId)

      return context.release('5s', { reason: 'provider_not_ready' })
    }

    await saveReport(payload.reportId, { signal: context.signal })
  },
})

context.release() records expected waiting, not failure. The run becomes released; after the delay, tick() requests delivery again and a worker executes the next attempt.

Add A Schedule

const dailyDigest = task({
  id: 'digests.daily',
  queue: emailQueue,
  schema: z.object({ tenantId: z.string().min(1) }),
  schedule: {
    id: 'digests.daily.default',
    cron: '0 9 * * *',
    timeZone: 'America/New_York',
    payload: { tenantId: 'tenant_123' },
  },
  async run(payload) {
    await sendDailyDigest(payload.tenantId)
  },
})

Register scheduled tasks in the runtime's task catalog and run maintenance:

const scheduledRunlane = createRunlane({
  lane: createLocalLane(),
  queues: [emailQueue],
  tasks: { dailyDigest },
})

await scheduledRunlane.tick()
await scheduledRunlane.worker({ mode: WorkerMode.Drain }).closed

tick() materializes due schedule occurrences and writes ordinary runs. It does not execute handlers; workers still execute the materialized runs.

Inspect History

import { RunEventSortField, SortDirection } from '@runlane/core'

const events = await runlane.runs.events(queuedRun.id, {
  sortBy: RunEventSortField.Sequence,
  sortDirection: SortDirection.Asc,
})

The local lane keeps append-only event history in memory, so tests can assert public behavior through runs and events instead of mocking internal functions.

Local Limits

BehaviorLocal lane result
Process restartAll runs, events, schedules, leases, idempotency owners, and outbox rows are lost.
Idempotency and singleton keysEnforced only inside the current process.
Queue concurrencyEnforced only inside the current process.
Process boundariesA second process cannot load the same state directly; CLI dev proxying is narrow.
Wakeup transportPublishes best-effort local wakeups; storage outbox state is still in memory.
Production crash recoveryNot proven. Use a production storage adapter and conformance tests.
Operator APIsAvailable for development and tests through runlane.runs.*.

On this page