Retries are safe.Charges happen once.

Add Idempotency-Key support to any Elysia route. A retried request gets the original response instead of running your handler again.

Playground

Retries get the original response

Send a $42.00 payment, lose responses on the way back, click twice. The receipt prints a line only when the server really charges the card.

Scenarios

Sent 3Ran 1Charges 1Duplicates 0

Uses key 6d66…c0dd
Idempotency-Key header
CompareWithout and with a key, same network
400 ms
0 %
2

Timeline With key

3 attempts
What happened

The server finished the first time. The retries got the saved response.

Requests sent
3
Handler runs
1
Charges
1
Duplicates
0 duplicate charges
With key

ACME COFFEE · SERVER LEDGER


  1. CHARGE$42.00card •••• 4242#109:41:00.440

TOTAL CHARGED$42.00

1 charge

Uses key 6d66…c0dd

State machine

Every key moves through four states

One record per key, scoped to the client that sent it. Each step is a single atomic update in the store, so two requests can never both take the lock.

  1. New

    No record for this key yet. The first request takes a lock and your handler runs.

  2. begin
  3. In progress

    The lock lives for lockTimeout. The same key meanwhile gets 409 with Retry-After: 1.

  4. 2xx to 4xx
  5. Completed

    The response is kept for retention. The same request gets it back with Idempotent-Replayed: true; a different body gets 422.

  6. From In progress: 5xx or a throw
  7. Released

    The lock is removed and the client gets the original error. A retry runs the handler again.

Status codes

What each status tells the client

A replay carries the original status and body, plus Idempotent-Replayed: true. The plugin only ever adds these four.

StatusMeaningWhat the client should do
400The key is missing on a route with required: true, or the key is invalid.Send a valid key. Do not retry the same request as is.
409The first request with this key has not finished yet.Wait for Retry-After seconds, then retry with the same key.
422The key was already used for a different body, path, or method.A client bug: use a new key for a new operation.
503The store failed, and failureMode is 'closed' (the default).Wait for Retry-After seconds, then retry with the same key.

Usage

One line protects a route

Every example is copied from the README. Keys are kept once per client, for 24 hours by default.

Quick start

import { Elysia } from 'elysia'
import { idempotency } from 'elysia-idempotency'

let charges = 0

const app = new Elysia()
  .use(idempotency())
  .post('/charges', ({ body }) => ({ charge: ++charges, body }))
  .listen(3000)

Require a key

import { Elysia } from 'elysia'
import { idempotency } from 'elysia-idempotency'

const orders = new Elysia({ prefix: '/orders' })
  .use(idempotency({ required: true, prefix: 'orders' }))
  .post('/', () => ({ created: true }))
  .patch('/:id', ({ params }) => ({ updated: params.id }))

const app = new Elysia()
  .post('/search', () => ({ results: [] }))
  .use(orders)

Redis

import { RedisClient } from 'bun'
import { Elysia } from 'elysia'
import { RedisStore, idempotency } from 'elysia-idempotency'

const redis = new RedisClient(process.env.REDIS_URL ?? 'redis://localhost:6379')
const store = new RedisStore({ client: redis })

const app = new Elysia()
  .use(idempotency({ store }))
  .post('/orders', () => ({ created: true }))
  .onStop(() => redis.close())

Cookie-session identity

import { Elysia } from 'elysia'
import { idempotency } from 'elysia-idempotency'

const sessions = new Map<string, { readonly userId: string }>()

const app = new Elysia()
  .use(
    idempotency({
      identity: ({ elysia, ip }) => {
        const sessionId = elysia.cookie.session?.value
        const session = typeof sessionId === 'string' ? sessions.get(sessionId) : undefined
        if (session !== undefined) return `user:${session.userId}`
        return ip === null ? 'anonymous' : `ip:${ip}`
      },
    }),
  )
  .post('/posts', () => ({ created: true }))

Use with elysia-rate-limiter

import { Elysia } from 'elysia'
import { idempotency } from 'elysia-idempotency'
import { rateLimit } from 'elysia-rate-limiter'

const app = new Elysia()
  .use(rateLimit({ limit: 20, window: '1m' }))
  .use(idempotency())
  .post('/orders', () => ({ created: true }))

Engine in a queue consumer

import { IdempotencyEngine } from 'elysia-idempotency'

interface OrderPaid {
  readonly messageId: string
  readonly orderId: string
}

const engine = new IdempotencyEngine({ retention: '7d', lockTimeout: '5m' })

const shipOrder = async (orderId: string): Promise<void> => {
  console.log(`shipping ${orderId}`)
}

export const onOrderPaid = async (message: OrderPaid): Promise<'done' | 'duplicate' | 'busy'> => {
  const fingerprint = Bun.CryptoHasher.hash('sha256', JSON.stringify(message), 'hex')
  const outcome = await engine.begin({ scope: 'order-paid', key: message.messageId, fingerprint })
  if (outcome.kind === 'replay' || outcome.kind === 'mismatch') return 'duplicate'
  if (outcome.kind === 'in-progress') return 'busy'
  try {
    await shipOrder(message.orderId)
    await engine.complete(outcome.lease, { status: 200, headers: [], body: null, bodyOmitted: false })
    return 'done'
  } catch (error) {
    await engine.release(outcome.lease)
    throw error
  }
}

Questions

Questions developers ask

Does this replace unique constraints?

No. The plugin stops the same request from running twice. It cannot stop two different requests (two keys, or no key at all) from creating the same order, and it cannot help once a record expires or the store is wiped. Keep unique constraints and transactions in your database, and treat this plugin as the layer that makes client retries safe.

Why not PUT or DELETE?

By default the plugin covers POST and PATCH, the methods that usually create or change something each time they run. PUT and DELETE are meant to be idempotent already. If yours are not, add them with the methods option. GET, HEAD, and OPTIONS cannot be added.

How long are keys kept?

A completed response is replayed for retention, 24 hours by default and up to 30 days. An unfinished lock lives for lockTimeout, 1 minute by default. After that, the same key counts as a new request, as the “Next day” scenario shows.

What happens with more than one server?

The default MemoryStore lives inside one process, so a retry that lands on another instance runs the handler again. Use RedisStore: every update is a compare-and-set Lua script, so two identical requests on different instances still never both run the handler.

What if my handler is slower than lockTimeout?

The lock expires while the handler is still running, so a retry can run the handler a second time. Only one of the two responses is stored. Set lockTimeout comfortably above your slowest handler.

Why does a 500 not get stored?

A 5xx or a thrown error means the work did not finish. Storing it would replay the failure forever, even after the database comes back. So the plugin releases the key and the retry runs again, as in the “Server hiccup” scenario. To store a final error, return it with a 4xx such as status(402, ...).