Skip to content
You are reading the v2 docs, currently in beta.V1 docs
oRPC
Esc
navigateopen⌘Jpreview
On this page

Lock Helpers

Prevent the same work from running concurrently in oRPC with a unified Locker interface, storage adapters, and procedure middleware.

Installation

npm install @orpc/experimental-lock@beta
pnpm add @orpc/experimental-lock@beta
yarn add @orpc/experimental-lock@beta
bun add @orpc/experimental-lock@beta

Basic Usage

The core concept is the Locker interface, which defines a standard way to run work under a lock, so that work sharing the same key never runs concurrently. You can create your own custom locker or use one of the provided adapters:

The lock method runs a callback while holding the lock for a key, and releases the lock afterwards, even when the callback throws. The callback receives waited, which tells whether the lock was acquired immediately (false) or only after waiting for another holder to release it (true).

const const locker: MemoryLockerlocker = new new MemoryLocker(options?: MemoryLockerOptions): MemoryLocker
Locker adapter backed by in-memory storage, so locks are only shared within the current process. Waiters acquire the lock in order, without polling.
@see{@link https://orpc.dev/docs/helpers/lock#adapters Lock Helpers - Adapters}
MemoryLocker
()
const
const report: {
    url: string;
}
report
= await const locker: MemoryLockerlocker.
MemoryLocker.lock<{
    url: string;
}>(key: string, fn: (options: LockCallbackOptions) => Promisable<{
    url: string;
}>, options?: LockOptions): Promise<{
    url: string;
}>
Runs `fn` while holding the lock for `key`, and releases the lock afterwards, even when `fn` throws.
@throws{LockTimeoutError} when the lock cannot be acquired before the timeout elapses
lock
('report:123', async ({ waited: boolean
Whether the lock was acquired only after waiting for another holder to release it. `false` means the lock was acquired immediately.
waited
}) => {
if (waited: boolean
Whether the lock was acquired only after waiting for another holder to release it. `false` means the lock was acquired immediately.
waited
) {
// Another holder just finished the same work, so its result may already be available const
const cached: {
    url: string;
} | undefined
cached
= await
const cache: {
    get: (key: string) => Promise<{
        url: string;
    } | undefined>;
}
cache
.
get: (key: string) => Promise<{
    url: string;
} | undefined>
get
('report:123')
if (
const cached: {
    url: string;
} | undefined
cached
) {
return
const cached: {
    url: string;
}
cached
} } return await
function generateReport(id: string): Promise<{
    url: string;
}>
generateReport
('123')
})

Options

  • ttl: how long the lock is held before it expires automatically, in milliseconds. Guards against holders that never release the lock. Each adapter has its own default.
  • timeout: how long to wait for the lock to become available, in milliseconds. lock rejects with a LockTimeoutError once it elapses.
  • signal: stops waiting early and rejects with the abort reason. Has no effect once the lock is acquired.
await locker.lock('report:123', () => generateReport('123'), {
  ttl: 30_000,
  timeout: 5_000,
  signal: request.signal,
})

Lock Middleware

The lock helper creates middleware that runs procedures under a lock, so that calls sharing the same key never run concurrently. When the lock cannot be acquired before the timeout elapses, the procedure rejects with a CONFLICT error. The request signal is forwarded, so a call stops waiting as soon as the client disconnects.

import { lock, Locker } from '@orpc/experimental-lock'

const procedure = os
  .$context<{ locker: Locker }>()
  .input(z.object({ id: z.string() }))
  .use(
    lock({
      locker: ({ context }) => context.locker,
      key: ({ context }, input) => `report:${input.id}`,
      /**
       * How long the lock is held before it expires automatically, in milliseconds.
       *
       * @default the adapter default
       */
      ttl: 30_000,

      /**
       * How long to wait for the lock to become available, in milliseconds.
       * The procedure rejects with a `CONFLICT` error when it elapses before the lock is acquired.
       *
       * @default the adapter default
       */
      timeout: 5_000,
    }),
  )
  .handler(async ({ context, input }) => {
    if (context['lock/waited']) {
      // Another call with the same key just finished, so its result may already be available
    }

    return await generateReport(input.id)
  })

const locker = new MemoryLocker()

const result = await call(
  procedure,
  { id: '123' },
  { context: { locker } }
)

Adapters

Memory

Keeps locks in the memory of the current process, so they are not shared between instances. A good fit for development, tests, and single-process servers.

import { MemoryLocker } from '@orpc/experimental-lock/memory'

const locker = new MemoryLocker({
  /**
   * How long a lock is held before it expires automatically, in milliseconds.
   * Can be overridden per call.
   *
   * @default undefined (held until released)
   */
  ttl: 30_000,

  /**
   * How long to wait for a lock to become available, in milliseconds.
   * Can be overridden per call.
   *
   * @default 10000
   */
  timeout: 5_000,
})

Redis

Stores locks in Redis, so every instance using the same server shares the same locks. Works with both standalone and cluster clients.

import { RedisLocker } from '@orpc/experimental-lock/redis'
import { createClient } from 'redis'

// Both standalone (`createClient`) and cluster (`createCluster`) clients are supported.
const client = createClient({ url: 'redis://localhost:6379' })

// RedisLocker lazily connects to Redis when needed.
// You can still call `client.connect()` manually, but it is optional.
await client.connect()

const locker = new RedisLocker(client, {
  /**
   * The prefix to use for Redis keys.
   *
   * @default ''
   */
  prefix: '',

  /**
   * How long a lock is held before it expires automatically, in milliseconds.
   * Can be overridden per call.
   */
  ttl: 30_000,

  /**
   * How long to wait for a lock to become available, in milliseconds.
   * Can be overridden per call.
   *
   * @default 10000
   */
  timeout: 5_000,

  /**
   * How long to wait between acquisition attempts while the lock
   * is held by someone else, in milliseconds.
   *
   * @default 100
   */
  retryInterval: 100,
})

Upstash

Stores locks in Upstash Redis, so it shares locks with RedisLocker. A good fit for serverless and edge runtimes, keeping in mind that every acquisition attempt is a REST request.

import { UpstashLocker } from '@orpc/experimental-lock/upstash'
import { Redis } from '@upstash/redis'

const redis = Redis.fromEnv()

const locker = new UpstashLocker(redis, {
  /**
   * The prefix to use for Redis keys.
   *
   * @default ''
   */
  prefix: '',

  /**
   * How long a lock is held before it expires automatically, in milliseconds.
   * Can be overridden per call.
   */
  ttl: 30_000,

  /**
   * How long to wait for a lock to become available, in milliseconds.
   * Can be overridden per call.
   *
   * @default 10000
   */
  timeout: 5_000,

  /**
   * How long to wait between acquisition attempts while the lock
   * is held by someone else, in milliseconds.
   *
   * @default 100
   */
  retryInterval: 100,
})

Bun Redis

The Redis adapter for Bun’s built-in Redis client, with no extra dependency. It shares locks with RedisLocker.

import { experimental_BunRedisLocker as BunRedisLocker } from '@orpc/bun'
import { redis } from 'bun'

const locker = new BunRedisLocker(redis, {
  /**
   * The prefix to use for Redis keys.
   *
   * @default ''
   */
  prefix: '',

  /**
   * How long a lock is held before it expires automatically, in milliseconds.
   * Can be overridden per call.
   */
  ttl: 30_000,

  /**
   * How long to wait for a lock to become available, in milliseconds.
   * Can be overridden per call.
   *
   * @default 10000
   */
  timeout: 5_000,

  /**
   * How long to wait between acquisition attempts while the lock
   * is held by someone else, in milliseconds.
   *
   * @default 100
   */
  retryInterval: 100,
})

Cloudflare

Stores locks in Durable Objects, so every Worker instance shares the same locks. Export a class extending DurableLockObject, bind it in your Wrangler config, and pass the namespace to DurableLocker.

{
  "durable_objects": {
    "bindings": [{ "name": "LOCK_DON", "class_name": "LockDO" }]
  },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["LockDO"] }]
}
import { experimental_DurableLocker as DurableLocker, experimental_DurableLockObject as DurableLockObject } from '@orpc/cloudflare'

export class LockDO extends DurableLockObject {}

export default {
  async fetch(request, env) {
    const locker = new DurableLocker(env.LOCK_DON, {
      /**
       * The prefix to use for Durable Object names.
       *
       * @default ''
       */
      prefix: '',

      /**
       * How long a lock is held before it expires automatically, in milliseconds.
       * Can be overridden per call.
       */
      ttl: 30_000,

      /**
       * How long to wait for a lock to become available, in milliseconds.
       * Can be overridden per call.
       *
       * @default 10000
       */
      timeout: 5_000,

      /**
       * Custom function to get the Durable Object stub for a lock key.
       *
       * @default ((namespace, key) => namespace.getByName(key))
       */
      getStubByName: (namespace, key) => namespace.getByName(key),
    })
  },
}

Last updated on September 14, 2026

Was this page helpful?