typescript 125 lines · 3 tabs

Prisma transaction with retries for serialization errors

Shared by codesnips Jan 2026
3 tabs
import { Prisma, PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

type TxClient = Prisma.TransactionClient;

interface RetryOptions {
  maxRetries?: number;
  baseDelayMs?: number;
  maxDelayMs?: number;
}

const SERIALIZATION_SQLSTATES = new Set(["40001", "40P01"]);

function isSerializationError(err: unknown): boolean {
  if (err instanceof Prisma.PrismaClientKnownRequestError) {
    if (err.code === "P2034") return true;
    const sqlState = (err.meta && (err.meta.code as string)) || "";
    if (SERIALIZATION_SQLSTATES.has(sqlState)) return true;
  }
  const msg = err instanceof Error ? err.message : String(err);
  return /could not serialize access|deadlock detected/i.test(msg);
}

function computeDelay(attempt: number, base: number, max: number): number {
  const backoff = Math.min(max, base * 2 ** attempt);
  return Math.floor(Math.random() * backoff);
}

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export async function retryableTransaction<T>(
  fn: (tx: TxClient) => Promise<T>,
  opts: RetryOptions = {}
): Promise<T> {
  const { maxRetries = 5, baseDelayMs = 25, maxDelayMs = 500 } = opts;
  let lastError: unknown;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await prisma.$transaction(fn, {
        isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
      });
    } catch (err) {
      lastError = err;
      if (!isSerializationError(err) || attempt === maxRetries) throw err;
      await sleep(computeDelay(attempt, baseDelayMs, maxDelayMs));
    }
  }

  throw lastError;
}

export { prisma };
3 files · typescript Explain with highlit

This snippet shows how to run Prisma interactive transactions at the Serializable isolation level and survive the conflicts that isolation level inevitably produces. When two transactions read overlapping data and then write, Postgres may abort one of them with SQLSTATE 40001 (could not serialize access) or 40P01 (deadlock detected). Those errors are not bugs — they are the database telling the application to retry the whole transaction. The correct response is to re-run the transaction body, not to lower the isolation level.

In retryableTransaction, the core is a helper that wraps prisma.$transaction in a bounded loop. Prisma surfaces database errors as a PrismaClientKnownRequestError carrying a code; for raw serialization conflicts the driver code lands in meta.code or the error message, so isSerializationError inspects both the Prisma code (P2034, Prisma's own "transaction failed due to a write conflict or deadlock") and the underlying Postgres SQLSTATE. Only those transient codes are retried — any other error is rethrown immediately so genuine bugs and constraint violations surface fast.

The retry loop uses exponential backoff with jitter via computeDelay, which spreads competing clients apart instead of having them collide again on a fixed schedule. After maxRetries attempts the last error is rethrown so callers still get a real failure rather than an infinite hang. Crucially, the transaction body must be idempotent-on-retry: it receives a fresh tx client each attempt, so all reads and writes go through tx, never the outer prisma, otherwise retried work would escape the transaction.

In transferFunds, the pattern is applied to a classic write-skew scenario: reading two account balances and updating them. Under Serializable, a concurrent transfer touching the same rows will abort one caller, which retryableTransaction transparently retries. The explicit balance check inside the transaction is safe precisely because serialization guarantees the read set stays consistent.

The trade-off is throughput under contention — retries cost latency and CPU — so the isolation level is reserved for correctness-critical paths. A subtle pitfall is doing non-idempotent side effects (sending email, calling external APIs) inside the transaction body; those must be deferred until after the transaction commits, since the body can run several times.


Related snips

Share this code

Here's the card — post it anywhere.

Prisma transaction with retries for serialization errors — share card
Link copied