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> --eventsRestart 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 }).closedtick() 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
| Behavior | Local lane result |
|---|---|
| Process restart | All runs, events, schedules, leases, idempotency owners, and outbox rows are lost. |
| Idempotency and singleton keys | Enforced only inside the current process. |
| Queue concurrency | Enforced only inside the current process. |
| Process boundaries | A second process cannot load the same state directly; CLI dev proxying is narrow. |
| Wakeup transport | Publishes best-effort local wakeups; storage outbox state is still in memory. |
| Production crash recovery | Not proven. Use a production storage adapter and conformance tests. |
| Operator APIs | Available for development and tests through runlane.runs.*. |