Skip to content
advanced Phase 11 · Databases and ORM Integration

HTTP Drivers at the Edge

Query databases from runtimes without TCP sockets.

45m
0 problems
Topic Progress 0%

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?

Question 1 options

2. Which property describes an HTTP database driver?

Question 2 options

3. 200 concurrent serverless invocations each open a pool of 10 against max_connections = 100. What is the correct fix?

Question 3 options

4. You point Prisma at Supabase port 6543 (transaction pooler) without `?pgbouncer=true`. What goes wrong?

Question 4 options

Flashcards

Question

What does the edge runtime lack that a traditional TCP database driver needs?

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?

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?

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.

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?

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

Continue Learning