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
| 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:
import { db, dbSession } from "@/db";
dbis 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.dbSessioncan 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 realWebSocket: 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, neverdbordbSession, inside. A straydb.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.