# 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.

You write the obvious thing:

```ts
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

| Driver | Interactive `db.transaction()` | Notes |
|---|---|---|
| `drizzle-orm/neon-http` | no | One HTTP request per statement. Fastest cold start. |
| `drizzle-orm/neon-serverless` | yes | WebSocket to a real session. Costs a connection. |
| `drizzle-orm/postgres-js` | yes | Plain 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:

```ts
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.

```ts
// 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([...])`:

```ts
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.

```ts
// 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:

```ts
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
