I built a Rork app's backend on Neon and Drizzle, and locally it felt great. A few days into production, one screen would occasionally spike in latency. The cause was mundane: from the edge I was sending each query as its own HTTP round trip, and that one screen fired several small queries in a row.
Local Postgres lives on the same machine, so the round trips are invisible. Move to the edge and the number of times you touch the database in a single request becomes your felt speed. There's plenty written about getting Neon and Drizzle to the "first working query." What matters in production is the decisions that come after. As an indie developer running several apps at once, the "after it works" handling is exactly where my time went. Here are the places I actually had to revisit.
Decide the connection model first — neon-http or the WebSocket Pool
@neondatabase/serverless gives you two doors: the HTTP driver returned by neon(), and the WebSocket driver built on Pool. Most tutorials only show the first, but in production this is the decision to make up front.
The HTTP driver sends each query as a single fetch. It fits edge runtimes like Cloudflare Workers, where you can't hold a connection open, and it shrugs off cold starts. The trade-off: it can't run interactive transactions that group several statements into one atomic unit.
// src/db/http.ts — the path for single, standalone queries
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
import * as schema from "./schema";
export function createHttpDb(databaseUrl: string) {
const sql = neon(databaseUrl);
return drizzle(sql, { schema });
}When you need a Stripe payment confirmation and a purchase-history insert to "both succeed or both fail," you open a transaction over the WebSocket Pool. Half-finished writes around payments are expensive to clean up, so this is the one place I never compromise on a transaction.
// src/db/pool.ts — the path for code that needs a transaction
import { drizzle } from "drizzle-orm/neon-serverless";
import { Pool } from "@neondatabase/serverless";
import * as schema from "./schema";
export function createPoolDb(databaseUrl: string) {
const pool = new Pool({ connectionString: databaseUrl });
return drizzle(pool, { schema });
}In my case I recommend splitting into two paths: reads and single writes over HTTP, and only the endpoints that must keep several rows consistent over the Pool. Route everything through the Pool and the WebSocket handshake makes the first call slower; route everything through HTTP and you end up reimplementing consistency outside the ORM. The deciding question is: does this endpoint need multiple writes to land as a single result? If not, HTTP stays lighter and faster — that's what the measurements showed.
Geography matters too. If the Neon region and your main users are far apart, every HTTP round trip carries a fixed delay. If your users are concentrated in one region, put Neon near them. A fast edge doesn't shorten the physical distance to the database.
Count how many times one request touches the database
The spiky latency almost always traces back to queries per screen. Because Drizzle's query builder reads so plainly, it's tempting to fetch a list and then pull related rows inside a loop. That's N+1.
// Anti-pattern: fetch the author N times for N posts
const posts = await db.select().from(postsTable).limit(20);
for (const p of posts) {
// this one line becomes 20 round trips
p.author = await db.select().from(users).where(eq(users.id, p.userId));
}At the edge, those 20 round trips turn into hundreds of milliseconds of felt delay. In my measurements, collapsing that same screen into a single join made responses roughly 3x faster. Drizzle's relational queries let you pull the joins you need in one shot.
// Better: declare the relation and fetch it once
const posts = await db.query.postsTable.findMany({
limit: 20,
orderBy: (p, { desc }) => [desc(p.createdAt)],
with: {
author: {
columns: { id: true, displayName: true, avatarUrl: true },
},
},
});Narrowing columns here isn't cosmetic. Pulling the full body and internal flags you never render adds up in transfer size and serialization cost. Edge functions cap CPU time, so trimming returned columns to what you actually use pays off.
Aggregates are the same. For a value where you only want the number — a post count, say — don't fetch all the rows and read length; let the database count with $count.
const postCount = await db.$count(postsTable, eq(postsTable.userId, userId));What helped most in practice was making query counts visible during development. Turn on Drizzle's logger and print queries-per-request, and every time you build a screen you can eyeball "did this list quietly become N+1?" That's far cheaper than discovering it in production.
const db = drizzle(sql, { schema, logger: env.ENVIRONMENT !== "production" });