AZ.dev

Serialize per-user check-then-write flows with pg_advisory_xact_lock(hashtext(id))

When the same user can race against themselves on a check-then-write flow (multi-tab uploads, double-clicked submits), wrap the whole flow in a transaction holding a per-user advisory lock. Other users keep going without contention.

The pattern

async function withUserLock<T>(
  tx: Prisma.TransactionClient,
  userId: string,
  body: () => Promise<T>,
): Promise<T> {
  await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${userId}))`;
  return body();
}

await db.$transaction(async (tx) => {
  return withUserLock(tx, userId, async () => {
    const used = await sumUsedBytes(tx, userId);
    if (used + incomingBytes > cap) {
      throw new QuotaExceededError(used, cap);
    }
    return tx.userImage.create({ data: { userId, byteSize: incomingBytes, ... } });
  });
});

Why this and not the alternatives

Why hashtext(userId) and not the raw string

pg_advisory_xact_lock takes a bigint (or two int4s). User IDs are typically cuid or uuid strings. hashtext(text) → int4 is the standard collapse: deterministic, cheap, no extension required.

-- Both forms work; the bigint variant is fine and gives a wider key space.
SELECT pg_advisory_xact_lock(hashtext('user-abc-123'));
SELECT pg_advisory_xact_lock(hashtext('user-abc-123')::bigint);

Gotchas

When you don’t need this

Otherwise: any flow where the new row’s validity depends on the existing aggregate of the user’s rows, and where the same user can fire two requests at once, needs serialization.

postgres prisma concurrency advisory-lock quota race-condition

Was this helpful?

Related Entries

Reproduce Railway's npm ci locally with the exact npm versionContent-hash dedup that restores a soft-deleted row must re-run quota and limit checksA storage URL builder and its static file handler must share one prefix constantFix "Cannot find module" with ES modules in Node.js