Runlane
Concepts

Local Development

Use the local lane as a real in-process Runlane backend for development and tests.

@runlane/lane-local is the local development backend for Runlane. Use it with createRunlane() when you want the full runtime experience in one process:

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

const emailQueue = queue({ name: 'emails', default: true })

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

await runlane.trigger(runlane.tasks.sendEmail, { userId: 'user_123' })
await runlane.executeNext()

The local lane stores runs, events, leases, schedule occurrences, bounded-queue reservations, idempotency and singleton ownership, and outbox rows in memory. That makes it useful for development, examples, and application tests that should exercise real Runlane behavior without a database, queue, or cloud account. It reports processLocalState: true and productionDurable: false; process exit loses all local state.

Process boundaries are real. A runlane dev process and a separate process that each construct createLocalLane() get different in-memory stores. The CLI handles the common two-terminal workflow by having the long-running runlane dev process own a loopback control server when the runtime lane reports storage.capabilities.processLocalState: true; stateful commands such as runlane trigger, runlane tick, runlane runs list, runlane runs get, runlane retry, runlane rerun, runlane cancel, and runlane prune proxy to the matching dev session, so they run inside the process that owns the local lane. Other application processes still need to call into the same process or use a shared lane when they need cross-process state.

createLocalLane() options:

OptionRequiredDefaultWhat it controls
nameNolocalLane diagnostic name surfaced through the lane contract. It must be a non-empty string.

What It Provides

From a user's point of view, createLocalLane() should feel like a complete local Runlane backend. You can trigger tasks, run runNow() for current-process execution, run executeNext(), start local workers, call tick() for schedules, delivery requests, cancellation finalization, and outbox publishing, and inspect or control runs through runlane.runs.

Internally, the split stays simple:

@runlane/core
  owns trigger, workers, schedules, retry, release, cancellation, and operator APIs

@runlane/local-adapters
  owns in-memory storage and in-memory wakeup publishing

@runlane/lane-local
  composes the local storage and transport adapters into one Lane

That separation is the point. The same core runtime semantics run over local memory today and production storage and transport later.

The storage side is intentionally more realistic than a mock: it persists append-only run history, projected run records, leases, schedule occurrence claims, keyset pagination cursors, idempotency and singleton ownership, queue-capacity reservations, pruning, and an outbox. The transport side is intentionally best-effort: it acknowledges wakeup publish attempts immediately and does not provide durable delivery, ordering, grouping, or native delays. Durable wakeup recovery is represented by the local storage outbox and tick() path, not by the in-memory transport.

CLI Development Loop

Use runlane dev when you want the local runtime to behave like a running backend process:

# Terminal 1
pnpm exec runlane dev

# Terminal 2, using the same resolved Runlane config path
pnpm exec runlane trigger emails.welcome '{"userId":"user_123"}'
pnpm exec runlane runs list
pnpm exec runlane runs get <runId> --events

The long-running dev command starts a polling worker and a maintenance loop. The maintenance loop calls tick() on the configured interval, so due schedules are materialized, deferred delivery requests are appended, expired cancellations are finalized, and outbox rows are published. When the lane is process-local, the dev command also writes a temporary session file keyed by the resolved config path and accepts authenticated loopback requests from matching stateful CLI commands.

runlane dev --once is different: it runs one maintenance pass, starts a drain worker, waits for due work to finish, and exits. Use it for scripts and tests that need deterministic local execution. Because it exits after the one-shot pass, it does not provide a long-lived bridge for a second terminal.

Testing Applications

Prefer testing your app against a real local runtime instead of mocking Runlane. Mock your own side-effect dependencies, such as email providers, payment clients, or HTTP clients, and let Runlane create and execute real runs:

import { createRunlane, queue, task, WorkerMode } from '@runlane/core'
import { createLocalLane } from '@runlane/lane-local'
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() }),
  async run(payload) {
    sentEmails.push(payload.userId)
  },
})

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

await runlane.trigger(runlane.tasks.sendWelcomeEmail, { userId: 'user_123' })

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

Use WorkerMode.Drain in tests when you want deterministic worker execution. Drain mode processes currently due work and exits; it does not sleep or keep a polling loop alive. Use executeNext() when a test should execute exactly one available run.

For pure unit tests, put business logic behind ordinary functions and test those directly. Use the local lane for integration tests where you care about payload validation, durable run state, retry/release behavior, schedule materialization, worker acquisition, cancellation, or operator-visible history.

The local lane integration tests in Runlane exercise the same path application tests should use: createRunlane({ lane: createLocalLane(), ... }), then public runtime calls such as trigger(), executeNext(), worker({ mode: WorkerMode.Drain }), tick(), and runlane.runs.*. Adapter-level tests live lower, in @runlane/local-adapters, and are for proving the storage and transport contracts directly.

Local Limits

Local state is process memory, not production durability.

Local behaviorWhat it provesWhat it does not prove
Runs, events, leases, schedules, and outbox rows live in memoryRuntime behavior and task-contract drift inside one processCrash recovery or restart durability
Idempotency, singleton, and bounded queue policy run in memoryApplication definitions use the right Runlane primitivesProduction locking or transaction isolation
runlane dev proxies stateful commands into a matching process-local dev sessionTwo-terminal local workflows with the same config pathCross-process shared storage outside the dev bridge
Local transport acknowledges wakeups immediatelyRuntime outbox and publish plumbing are wiredProvider delivery guarantees, ordering, grouping, or native delays
Pruning works against local historyOperator retention flows call the public API correctlyProduction indexes, foreign keys, table bloat, or compaction cost

Use production adapter tests and storage conformance to prove database constraints, provider error mapping, migrations, and queue-specific delivery behavior.

Conformance

Conformance is Runlane's shared contract test vocabulary:

storage conformance
  proves a storage adapter obeys the run truth contract

transport conformance
  proves a transport adapter obeys the wakeup publish contract

lane composition conformance
  proves a lane wires compatible storage and transport adapters correctly

@runlane/local-adapters runs storage and transport conformance. @runlane/lane-local runs lane composition conformance and integration tests over @runlane/core to prove the local runtime experience. The local lane should be product-complete locally without becoming a second runtime implementation.

On this page