Why Your Query Works in `next dev` and Dies in Middleware
Move one database call into middleware.ts, deploy, and the same code that ran locally now fails at import time. Next.js has two runtimes, and the difference is capability, not speed:
| Capability | Node runtime (nodejs) |
Edge runtime |
|---|---|---|
Raw TCP sockets (net, tls) |
yes | no |
Node built-ins (fs, child_process) |
yes | no |
| Query engines that spawn a binary | yes | no |
Global fetch, crypto.subtle |
yes | yes |
| Runs where | your server / function region | points of presence near the user |
| Typical memory / time limit | generous | 128 MB, bounded request time |
Middleware always runs on the edge; App Router routes opt in with export const runtime = 'edge'. In that runtime, loading a TCP-based driver produces:
⨯ A Node.js API is used (net.Socket) which is not supported in the Edge Runtime.
Prisma's classic client fails for a related but distinct reason: its query engine is a long-lived process speaking the wire protocol over a socket, and there is no socket and no process to spawn. The failure happens when the module loads — before any query you wrote runs — which is why the stack trace points at an import rather than at your logic.
The second trap is geographic. Locally your URL says localhost:5432, and it resolves to the Postgres running on your laptop. An edge function executes on someone else's machine in another city or country: localhost there is that machine, so you get ECONNREFUSED — or, if you pointed at a real host, a private VPC address the edge network cannot route to at all. The error is not "database down"; it is "this runtime was never on your network."
So the design question is not can I query here but what transport survives here. Two properties define the answer: the driver must speak HTTP (anything fetch can do, the edge can do), and connection pooling must live somewhere that outlives the invocation. Before that, one callback to Applying Schema Migrations: migrate deploy belongs in a plain Node CI job — you cannot apply DDL from a runtime that has no socket to open. The transport options for queries themselves follow.
Knowing where each file executes saves you a debugging session:
| Code you write | Runtime it runs on |
|---|---|
middleware.ts |
edge (always) |
app/**/route.ts |
Node by default; export const runtime = 'edge' to opt in |
Server Components (page.tsx, layout.tsx) |
Node during SSR |
| Client components | the browser — no database access at all |
instrumentation.ts register() |
Node, once per process |
The standard fix is a division of labor: middleware handles the request — redirects, cookie reads, headers — and calls a Route Handler or Server Component for anything that needs the database. Recent Next.js lines are adding an experimental Node runtime for middleware as an opt-in; until it is stable, assume edge there. And remember the fourth row: a client component cannot reach your database through any runtime trick, because no server code runs in it.
The errors separate cleanly once you know the layers: an unsupported-API error names the missing module (net, fs) and appears at import time, a configuration error names the variable, and a network error names the host. Reading the first line of the message rather than the tail of the stack is usually enough to place the failure.
Querying over HTTPS: Neon's Serverless Driver, Supabase's Pooler, Prisma Adapters
An HTTP driver replaces the persistent socket with one HTTPS request per query — or per batch of queries. Nothing is held open, nothing survives the invocation, and it works anywhere fetch exists:
import { neon } from '@neondatabase/serverless';
const sql = neon(process.env.DATABASE_URL!); // the https endpoint is derived from the URL
export async function recentOrders(orgId: string) {
return sql`SELECT id, total, status FROM "Order"
WHERE org_id = ${orgId} ORDER BY created_at DESC LIMIT 10`;
}
The tagged template is not cosmetic: interpolated values are bound as parameters, so ' OR 1=1-- arrives as data, never as SQL. This is parameterized querying with the injection safety you'd otherwise hand-roll.
You do not have to abandon Prisma's API to get this transport. Driver adapters swap the engine's data layer while keeping the query API:
import { PrismaNeon } from '@prisma/adapter-neon';
const adapter = new PrismaNeon({ connectionString: process.env.DATABASE_URL! });
export const prisma = new PrismaClient({ adapter });
Alternatively, point datasource at a remote endpoint (prisma://... via Prisma Accelerate or a hosted proxy) and the engine plus pool run outside your process — the client becomes a thin HTTP caller suitable for edge middleware.
Supabase deserves specific knowledge because its connection string hides two different services:
| Endpoint | Port | Mode | Use for |
|---|---|---|---|
| Direct | 5432 | one session per connection | migrations, psql, long transactions |
| Session pooler | 5432 | sticky connection per client | prepared statements, ORM defaults |
| Transaction pooler | 6543 | multiplexed, per-transaction | serverless/edge traffic |
Point Prisma at port 6543 without ?pgbouncer=true and transactions behave strangely or die with prepared-statement errors such as prepared statement "..." already exists, because transaction-mode pooling cannot promise one backend the whole time a statement lives. The flag tells the client to avoid session-scoped assumptions.
What you give up with HTTP: no LISTEN/NOTIFY, no temporary tables, no session SET, and one round trip of TLS per request unless you batch statements into a single call. What you gain is a driver that has no connection state to lose — which is precisely what short-lived code needs.
The comparison is worth carrying into an interview:
TCP driver (pg, classic Prisma engine) |
HTTP driver | |
|---|---|---|
| Connection | long-lived socket, held by a pool | none — TLS per request or batch |
| First query on a cold start | connect + authenticate | handshake folded into the first call |
| Session features | SET, temp tables, LISTEN/NOTIFY |
unavailable by design |
| Multi-statement transaction | natural, across calls | confined to one batch request |
| Runs in the edge runtime | no | yes |
| Injection safety | bound parameters | bound parameters, serialized in the body |
Batching is the lever that makes the per-request cost acceptable — several statements travel in one HTTPS call:
const results = await sql.transaction([
sql`SELECT count(*) FROM "Order" WHERE status = 'PAID'`,
sql`SELECT sum(total) FROM "Order" WHERE created_at > now() - interval '7 days'`,
]);
If SQL is the wrong level entirely — you want row-level security and REST-shaped CRUD — Supabase's own client (@supabase/supabase-js, PostgREST over HTTPS) is a third option with its own trade-offs. Choose deliberately: the moment you need a CTE or a lock, you are back to the pooler ports.
Where the Pool Lives When Every Invocation Is Cold
Run the multiplication before choosing anything: 100 concurrent invocations × a pool of 10 = 1000 connections aimed at a database whose max_connections is 100. Serverless and edge code did not become "stateless" — the state merely moved to wherever you put the pool, and if the answer is "inside each invocation," you have built a connection storm with a cold start.
The invariant: the pool must live in a process that outlives your function. Since your function's lifetime is not under your control, the pool usually lives somewhere you don't run:
| Strategy | Pool location | What you take on | Fit |
|---|---|---|---|
| Self-hosted PgBouncer / RDS Proxy | a box near the DB | ops, upgrades, sizing | steady, known traffic |
| Managed pooler (Supabase 6543, Neon pooler) | the vendor | nothing; transaction-mode limits | most serverless apps |
| HTTP driver | the vendor's HTTPS endpoint | one round trip per query | edge reads, simple writes |
Prisma Accelerate (prisma://) |
Prisma's proxy | a vendor dependency, query cache | keeping Prisma on the edge |
Note what the last three have in common: from your code's perspective the pool disappeared — it became somebody's long-lived infrastructure with an SLA. That is the architectural move interviews are fishing for when they ask "how do you connect a serverless function to Postgres?"
Two numbers still deserve attention. Latency is per query now: an edge function in Tokyo talking to a database in Virginia pays ~150 ms each way on every call, so either place the compute near the data (regional functions, read replicas close to users) or accept that edge is for cache-shaped reads. And concurrency limits replace connection limits: the platform caps simultaneous invocations, which quietly becomes your effective database load ceiling — fewer, faster queries matter more than ever, which loops back to include instead of N+1.
A practical routing rule: keep transactions, migrations, and heavy ORM workloads on the Node runtime where pooling and sockets behave conventionally; use the edge for narrow, read-mostly queries with an HTTP driver. Split by route, not by ideology. When you next change table shape, remember migrate deploy belongs in a Node CI job from Applying Schema Migrations; after that, the roadmap moves on to client-side concerns in Local State with useState and useReducer.
If you do run your own pooler, its own limits become your new ceiling, and they fail with messages you should recognize before production teaches you:
ERROR: no more connections allowed (max_client_conn)
ERROR: server pool is at full capacity
PgBouncer's default_pool_size (server connections per user/database) and max_client_conn (client slots in front of them) are the two knobs; SHOW POOLS; in the admin console tells you whether requests are queuing or errors are imminent. Managed poolers expose the equivalent through dashboards — check them, because a pooler that saturates produces the same latency cliff you were avoiding, one layer further from your code.
Warm the paths you care about. With an HTTP driver the first request on a cold instance pays the TLS handshake — usually a few milliseconds — and subsequent calls on the same warm instance reuse that connection through the runtime's fetch implementation, so burst traffic after a cold start is cheap if your queries are few and batched. With a pooled client in Node, await prisma.$connect() at startup moves connection setup out of the first user request. Either way the goal is identical: pay setup costs when the instance boots, not while someone is waiting, and keep the count of queries per request small enough that neither number matters.
Quiz
1. middleware.ts instantiates PrismaClient with the default engine and the app fails at startup with "A Node.js API is used (net.Socket) which is not supported in the Edge Runtime". Why does it fail before any query runs?
2. Which property describes an HTTP database driver?
3. 200 concurrent serverless invocations each open a pool of 10 against max_connections = 100. What is the correct fix?
4. You point Prisma at Supabase port 6543 (transaction pooler) without `?pgbouncer=true`. What goes wrong?
Flashcards
Question
What does the edge runtime lack that a traditional TCP database driver needs?
Click to reveal answer
Answer
Raw TCP sockets (net/tls), Node built-ins like fs and child_process, and the ability to spawn a long-lived query-engine process. The client therefore fails at import time with a Node-API-not-supported error, not at query time.
Question
How does an HTTP driver execute a query, and what is the trade?
Click to reveal answer
Answer
One HTTPS round trip (or batch) carries the SQL and bound parameters to an endpoint the provider runs, so there is no connection state to leak when the invocation ends. You trade persistent-session features - LISTEN/NOTIFY, temp tables, session SET - and pay TLS latency per call unless you batch.
Question
What is a Prisma driver adapter and when do you need one?
Click to reveal answer
Answer
A package (e.g. @prisma/adapter-neon) that replaces the engine's data layer with a JS/HTTP driver while keeping the Prisma query API and generated types. It is what lets PrismaClient run in edge/serverless runtimes where the native engine cannot; the alternative is a prisma:// endpoint via Accelerate where the engine runs remotely.
Question
State the pooling invariant for serverless databases.
Click to reveal answer
Answer
The pool must live in a process that outlives your function. When invocations are short-lived, the pool moves to infrastructure you do not spin up: managed pooler (Supabase 6543, Neon pooler), a proxy (PgBouncer/RDS Proxy), an HTTP driver, or a remote engine like Prisma Accelerate.
Question
Supabase exposes several endpoints - which for what?
Click to reveal answer
Answer
Direct 5432: migrations and long transactions. Session pooler 5432: sticky connection per client, safe for prepared statements. Transaction pooler 6543: multiplexed, built for serverless concurrency - use with ?pgbouncer=true and accept session-feature limits.
Revision Notes
Key Takeaways
- 1. The edge runtime has no TCP sockets and cannot spawn a query engine, so classic drivers fail at import, not at query time
- 2. An HTTP driver turns each query into a fetch round trip - no connection state to leak, with session features as the cost
- 3. Driver adapters or a prisma:// endpoint keep the Prisma API while changing only the transport
- 4. Pooling must outlive the invocation: instances x pool size is checked against max_connections, or the pool moves to the vendor
- 5. Split routes by workload - transactions and migrations on Node, narrow reads at the edge - and keep migrations in a Node CI job
Interview Tips
- • "Why can't I use Prisma in middleware?" - no TCP sockets or process spawning in the edge runtime, so the engine cannot initialize; answer with the alternatives: driver adapter, prisma:// Accelerate, or move the route to Node
- • "How do serverless functions avoid exhausting database connections?" - the pool lives outside the functions (managed pooler/proxy/HTTP driver), plus the multiplication argument: instances x pool size vs max_connections
- • "HTTP vs TCP database driver - trade-offs?" - no persistent state and no cold socket leaks vs per-request TLS latency and no session features; batching and region placement recover most of the latency
- • "Supabase/Neon connection string looks odd - what is going on?" - multiple endpoints: direct for migrations, pooler for traffic; name the ports, the ?pgbouncer=true requirement, and what transaction mode forbids
Cheat Sheet
HTTP Drivers at the Edge
| Node runtime | Edge runtime | |
|---|---|---|
TCP sockets / net |
yes | no |
| spawn query engine | yes | no |
fetch, crypto.subtle |
yes | yes |
| location | function region | points of presence |
// HTTP driver - works wherever fetch exists
import { neon } from "@neondatabase/serverless";
const sql = neon(process.env.DATABASE_URL!);
const rows = await sql`SELECT id FROM "Order" WHERE org_id = ${orgId}`;
// Prisma with the same transport
import { PrismaNeon } from "@prisma/adapter-neon";
const prisma = new PrismaClient({ adapter: new PrismaNeon({ connectionString: url }) });
Pooling invariant: concurrent invocations x pool size <= max_connections - and since invocations multiply, move the pool out: managed pooler, PgBouncer/RDS Proxy, HTTP driver, or prisma:// Accelerate.
| Symptom | Cause |
|---|---|
| Node API (net) unsupported in Edge | TCP driver in the edge runtime |
ECONNREFUSED on localhost after deploy |
the database is not on the edge machine network |
| prepared statement errors on 6543 | transaction pooler without ?pgbouncer=true |
| latency spikes per query | edge region far from the database |