Skip to content

Transactions in Drizzle on serverless: what works over HTTP and what needs a socket

An interactive db.transaction() needs a real connection held open. On an HTTP driver it silently is not one. Here is what each driver supports and how to write atomic writes without holding a connection.

Drizzle ORM5 min readships at docs/solutions/drizzle/transactions-in-serverless.md

Tags: drizzle · transactions · serverless · neon · postgres · atomicity

You write the obvious thing:

await db.transaction(async (tx) => {
  const [order] = await tx.insert(orders).values(newOrder).returning();
  await tx.update(inventory).set({ stock: sql`stock - 1` }).where(eq(inventory.sku, sku));
  return order;
});

On a normal Postgres connection this is exactly right. On Neon's HTTP driver it either throws, or (worse, depending on version) runs the statements without the atomicity you think you have. The difference is not Drizzle's; it is what the underlying driver can physically do.

Why HTTP cannot hold a transaction

BEGIN … COMMIT is session state. It belongs to one connection, and every statement in between has to reach the same backend. An HTTP driver sends each statement as an independent request; there is no connection to keep, which is precisely why it is fast on a cold start and immune to connection exhaustion.

The same reasoning applies to a transaction-mode pooler (PgBouncer, Supavisor on 6543): a backend is yours for one transaction, so a transaction you open across several driver-level round trips can land on different backends.

The three drivers, and what each supports

DriverInteractive db.transaction()Notes
drizzle-orm/neon-httpnoOne HTTP request per statement. Fastest cold start.
drizzle-orm/neon-serverlessyesWebSocket to a real session. Costs a connection.
drizzle-orm/postgres-jsyesPlain TCP. Set max: 1, prepare: false behind a transaction pooler.

You do not choose between them at a call site, and you do not guess. This repo exports two handles from @/db, and src/db/driver.ts (written for the database battery this project was generated with) decides which driver backs each:

import { db, dbSession } from "@/db";
  • db is the default and the cheap one. On Neon it is the HTTP driver, so it cannot hold a transaction: db.transaction() type-checks and throws at runtime. On Postgres over a socket it is a normal session-capable client.
  • dbSession can always hold a session where the database can hold one at all. On Neon that is the WebSocket driver over the pooled connection, which needs a real WebSocket: Node 22+ or Bun. On Node 20 the Neon client routes pooled queries over HTTP instead and interactive transactions are not available; that is a runtime limitation, not a configuration you can flip.

Both are typed against the whole schema, and both go through the connection src/db/client.ts configured, so neither one opens a pool of its own.

Option 1: do not need a transaction

Most code that reaches for one does not. Two questions:

Can it be one statement? Postgres is atomic per statement. A single INSERT ... ON CONFLICT DO UPDATE, an UPDATE ... FROM, a WITH ... INSERT CTE: all atomic, no transaction required.

// Atomic without a transaction: one statement, one round trip.
await db
  .insert(billingCustomers)
  .values({ userId, stripeCustomerId })
  .onConflictDoNothing({ target: billingCustomers.userId });

Can it be idempotent instead of atomic? Webhook handlers, sync jobs and retries usually want "running this twice has the same effect" more than they want "these two writes are one". An idempotency key plus upserts is more robust than a transaction, because it also survives the process dying between the two writes.

Option 2: a batch, atomic, no connection held

Some drivers can send several statements in one request and commit them together. You lose the ability to branch on statement one's result, and you keep everything else. Drizzle spells it db.batch([...]):

await db.batch([
  db.insert(orders).values({ id, userId, total }),
  db.update(inventory).set({ stock: sql`stock - 1` }).where(eq(inventory.sku, sku)),
]);

This covers the large majority of "these must both land" cases in a web app.

Availability is the driver's, not Drizzle's: it exists on Neon's HTTP driver and on LibSQL, and not on postgres-js. If you generated this repo with Neon, the database battery also exports the raw form as batchTransaction from @/db, which takes the driver's own tagged template and is useful when the statements are SQL you would rather write out. On a plain Postgres connection, skip to option 3: you already have a session, so a transaction costs you nothing extra.

Option 3: a real interactive transaction

When you genuinely need to read, decide, then write (a balance check before a debit, a row locked with FOR UPDATE, an advisory lock) you need a session.

// dbSession, never db: on an HTTP driver `db.transaction()` throws.
const result = await dbSession.transaction(async (tx) => {
  const [account] = await tx
    .select()
    .from(accounts)
    .where(eq(accounts.id, accountId))
    .for("update");            // locks the row for the transaction's lifetime

  if (!account || account.balance < amount) {
    tx.rollback();             // throws; unwinds the transaction
  }

  await tx.update(accounts)
    .set({ balance: account.balance - amount })
    .where(eq(accounts.id, accountId));

  return tx.insert(ledger).values({ accountId, amount }).returning();
});

Rules for the body of that callback, all of which follow from "a connection is held for its whole duration":

  • No network calls inside. Charging a card or calling an LLM inside a transaction multiplies your connection hold time by someone else's latency. Do the call first, then open the transaction to record the result.
  • Keep it short. Milliseconds, not seconds. Add set local statement_timeout = '5s' if a statement could run away.
  • Use tx, never db or dbSession, inside. A stray db.insert(...) in the callback runs on a different connection, outside the transaction, and will not roll back. This is the most common transaction bug in any ORM.
  • Do not catch and swallow. Drizzle rolls back when the callback throws; swallowing the error commits a half-finished unit of work.
  • Order your writes consistently across the codebase (always accounts before ledger, never the reverse) so two concurrent transactions cannot deadlock.

Retrying serialization failures

Under REPEATABLE READ or SERIALIZABLE, Postgres can abort a transaction with SQLSTATE 40001. That is not a bug, it is the isolation level working, and the correct response is to retry:

async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
  for (let i = 0; ; i++) {
    try {
      return await fn();
    } catch (error) {
      const code = (error as { code?: string }).code;
      if (i >= attempts - 1 || (code !== "40001" && code !== "40P01")) throw error;
      await new Promise((r) => setTimeout(r, 25 * 2 ** i));
    }
  }
}

Only retry the whole transaction, never a statement inside one that has already aborted.

Choosing, in one paragraph

Default to db and single-statement atomicity. When two writes must land together and neither depends on the other's result, use a batch. When you must read-then-write under a lock, open a real transaction on dbSession, keep it free of network calls, use tx throughout, and hold it for as short a time as you can.