Region, runtime, and Fluid Compute
The three Vercel Function settings that govern how your deployed app performs: which one to change, and which two to leave alone.
Your code is on the internet now. The first deploy is green, the production URL resolves, and a git push ships each new version. Before you walk away, ask what the platform decided for you, and which of those decisions you need to override.
Three settings govern what you care about at deploy time: how slow the first request is, how slow every request is, and what the bill looks like. They are the region the function runs in, the runtime it ships on, and the Fluid Compute model that runs it. Of the three, exactly one needs a deliberate change on day one; the other two are already correct, and this lesson shows why you can leave them alone.
The one change is region. Every server action that reads your database pays a network round trip on every call. If the function and the database sit on opposite coasts, that round trip is the difference between a 30 ms query and a 110 ms one, on every request. It never shows up under pnpm dev, where everything sits next to everything else.
Match the function region to the database
Section titled “Match the function region to the database”A Vercel Function runs in a single region, and unless you say otherwise that region is iad1, a datacenter in Washington, D.C., on the US East coast. That default is fine until your database lives somewhere else.
Every request your app handles waits on the database. Each server action, route handler, and React Server Component that fetches data opens a connection to Postgres and waits for a reply. When the function and the database share a datacenter, that reply is a sub-millisecond hop across the room. When they sit a continent apart, the distance becomes a tax on every query of every request.
So the rule for a single-database app is blunt: the function region must match the database region. You chose that region back when you provisioned the database on Neon; the function has to sit next to it.
The trap is that a mismatch stays invisible until production. In local development the function and database are effectively co-located, so pnpm dev never surfaces the problem. Once deployed it shows up only as an elevated p95 : your average looks fine while the slow tail quietly carries a cross-country trip. That is why you set the region deliberately on day one.
Flip between the two panels and watch the function-to-database arrow.
Your database region is whatever region the Neon project was created in, visible in the Neon console. Match the function to that exact value, set in one of two places: the dashboard under Project Settings → Functions → Region, or a region field in a vercel.json file at the repo root, which you’ll see in the next section.
Vercel can also run a function in multiple regions at once, up to three on Pro and all of them on Enterprise. That is for global apps running database replicas in several regions, so a function near the user reads from a database near the user. A single-database app wants one region, matched.
The Node.js runtime and its limits
Section titled “The Node.js runtime and its limits”The second setting is the runtime , the engine your code runs on. Vercel offers two, and the point of this section is that you don’t have to choose.
By default, every Vercel Function ships on the Node.js runtime. That gives you the full Node.js API, every npm package including ones with native bindings, streaming responses, and a writable /tmp scratch directory. It’s what every server action, route handler, and RSC data fetch in your app runs on.
It’s the right default for what you’ve built. Your server code talks to Postgres through Drizzle, to Stripe, to Resend, and to R2, and every one of those libraries assumes it’s running on Node.js with the full package ecosystem underneath. The other runtime, Edge, can’t load most of them at all, which is why the next section is the only place you’d consider it.
Most projects need no configuration file. It’s still worth seeing the shape of the one you’d reach for, and where two of the three settings appear in it.
{ "$schema": "https://openapi.vercel.sh/vercel.json", "region": "iad1", "fluid": true}Most projects ship no vercel.json, because the Node.js runtime, the iad1 region, and Fluid Compute are already the defaults. The file exists only to override a default, and the one override worth making is the highlighted region line. The fluid: true line names the third setting, which the next sections unpack.
Under Fluid Compute, a function can run for up to 300 seconds, five minutes, on every plan, including the free Hobby tier, with higher ceilings on paid plans. The filesystem is read-only except for /tmp, which holds up to 500 MB.
That timeout is a ceiling, not a budget. It exists to absorb the occasional spike, a request that’s usually fast but sometimes slow, not to host work that’s slow by nature. Anything plausibly slow on purpose, such as generating a large export, calling a third-party API in a batch, or processing an image, belongs in a background job, the pattern you saw with Vercel’s after() and Trigger.dev. If you find yourself eyeing the timeout as a number to fit under, that’s the signal the work should have been backgrounded.
Large file uploads might look like they’d hit a request size limit, but they never go through the function. Uploads route directly from the browser to R2 through presigned URLs, the pattern from the object-storage chapter, so the function handles only a little metadata and its request-body limit never touches the upload path.
The Edge runtime, and when not to use it
Section titled “The Edge runtime, and when not to use it”There is a second runtime, and a common reflex says “turn on Edge, it’s faster.” Edge is a real tool with a narrow use case, but it is not a speed button. What you need is a decision rule.
The Edge runtime runs your code as a V8 isolate at a POP physically close to the user. The payoff is a much lower cold start than a Node.js container.
The costs are what the reflex ignores. On Edge you get no full Node.js API, no native modules, no general filesystem, and most npm packages won’t load. The part that matters most for a web app: only HTTP-based database drivers work there. Neon ships one of the few that fit, but your app’s pooled Postgres driver isn’t it. So for any route that queries Postgres through the pooled connection, calls Stripe, or pulls in a Node-only dependency, Edge is a downgrade: you lose more on capability than you gain on cold start.
That shapes the decision rule: stay on Node.js unless you have measured a latency problem on a specific path, and that path is stateless and shaped by physical proximity to the user, something tiny like a geolocation lookup or a dependency-free redirect. When that rare case is real, the opt-in is one line on a route handler or page segment:
export const runtime = 'edge';This is still valid in Next.js 16. Treat it as the rare exception, not a default.
One correction matters here, because writing from before 2026 tells you the opposite. You may have read that “middleware is Edge by default, and that’s where you do geolocation and A/B tests.” In Next.js 16, the file that was middleware.ts is renamed proxy.ts, and it runs on the Node.js runtime only. Setting its runtime option to Edge doesn’t get ignored; it throws a build error, because the Edge runtime isn’t available there at all. This course handles request-edge concerns like auth gating, response headers, and CSP in proxy.ts on Node.js, wired up in its own lessons.
Walk the decision yourself below. Each step is the next question to ask: capability before performance, measurement before optimization.
Edge can’t load Node-only dependencies: the pooled Postgres driver, the Stripe SDK, anything with native bindings. A faster cold start is irrelevant if the route can’t run at all. Keep it on Node.js.
Request-edge logic lives in proxy.ts, which runs on the Node.js runtime in Next.js 16.
Middleware isn’t an Edge thing anymore, and database queries don’t belong in the proxy regardless.
A faster cold start on a path that isn’t slow buys you nothing and costs you the full Node.js capability set. The default is correct until a number says otherwise.
This is the narrow case Edge is for: stateless, proximity-shaped, and measured. Opt in on that one route and leave everything else on Node.js.
Fluid Compute: one warm instance, many requests
Section titled “Fluid Compute: one warm instance, many requests”The third setting is different from the first two: you didn’t turn it on, and there’s nothing to turn. Fluid Compute is Vercel’s default execution model for Node.js functions, on by default for new projects since April 2025. It’s the same Node.js runtime you just committed to; what changed is how an instance runs requests.
Classic serverless ran one request per instance. A second request arriving while the first was still in flight got its own brand-new instance, and a fresh instance means a cold start, so a traffic spike became a storm of cold starts. Fluid runs one warm instance across many concurrent requests. While request A waits on a slow database query, the same instance picks up request B, reusing the dead time instead of spinning up new hardware.
This pays off for a web app because your workload is I/O-bound : a typical request spends most of its life waiting on Postgres or a third-party API. Under classic serverless that idle time was pure waste. Fluid fills it with other requests, so the same traffic needs fewer instances, which means fewer cold starts, lower latency, and a lower bill, with no code changes.
— with zero code changes.
Two facts close out the model. First, there is no concurrency dial. Vercel manages in-function concurrency automatically, filling a warm instance’s idle capacity before allocating a new one. You don’t pick a number or tune a maxConcurrency setting in vercel.json (older guides describe one, but it isn’t part of the current model). Your job isn’t to tune concurrency; it’s to write code that’s safe to run concurrently in one process, which is the trap the next section is about.
Second, errors are isolated: if one request throws an unhandled error, the others sharing that instance keep running, and Fluid logs the error while the in-flight requests finish. Errors are isolated, but memory is not, which is exactly why the next section exists.
Keep per-request state out of module scope
Section titled “Keep per-request state out of module scope”Here’s the shift in mental model. When an instance served one request at a time, anything stored at the top level of a module was per-request in practice: the next request got a fresh instance with fresh module state. Under Fluid that’s no longer true. Module-scope state is shared across every concurrent request on the instance, so several requests can touch it at once.
Most module-level code is safe, so don’t let that scare you off it. The Drizzle/Neon client you create once at module scope is fine: it’s a connection pool, built to be used by many concurrent callers. Stateless singletons, and ones that are internally safe for concurrent use, are correct and are the overwhelming majority of what belongs at module scope.
The unsafe case is narrow: a module-scope value that holds request-specific data and gets mutated per request. The canonical mistake is a hand-rolled in-memory cache, or a let currentOrgId at the top of a module that a request handler writes to. Under concurrency it leaks. Request A, acting for one tenant, writes its org id into that variable. Before A finishes, request B, a different tenant on the same instance, reads the variable and gets A’s value. That’s one customer’s data served to another, a cross-tenant leak, the worst class of bug a multi-tenant app can have. It’s invisible when you test one request at a time locally, because the bug needs two concurrent requests to exist.
The two snippets below are the same logic: one shape leaks, one doesn’t. The only difference is where the per-request value lives.
import { db } from '@/db';
let currentOrgId: string | null = null;
export const GET = async (request: Request) => { currentOrgId = request.headers.get('x-org-id'); const totals = await computeTotals(db, currentOrgId); return Response.json(totals);};This leaks across tenants. currentOrgId lives at module scope, so every concurrent request on the instance shares it. Request B can overwrite it between the moment A sets it and the moment A reads it back, so A computes totals for B’s organization. The bug only appears under concurrency, which is why local single-request testing never catches it.
import { db } from '@/db';
export const GET = async (request: Request) => { const orgId = request.headers.get('x-org-id'); const totals = await computeTotals(db, orgId); return Response.json(totals);};Per-request state stays per-request. orgId is a function local, so every invocation gets its own and concurrent requests can’t see each other’s value. The pooled db client stays at module scope on purpose: a connection pool is safe to share, which is what module scope is for.
When per-request state has to travel deep through a call stack without being passed as an argument to every function along the way, function locals aren’t enough. The answer is AsyncLocalStorage . You’ll see it carry a request id or the current org context elsewhere in the course; here, just know its name and the problem it solves.
Next.js states the same principle for proxy.ts: its docs warn that proxy code should not rely on shared modules or globals. Concurrent execution and shared top-level state don’t mix.
Decide where each of these belongs. Some are safe to create once at module scope and share; others hold per-request data and must stay inside the request. Drag each item into the bucket it belongs to, then press Check.
let currentUserId set from the incoming requestMap caching the current request’s computed totalsrequestId for log correlationCheck your understanding
Section titled “Check your understanding”A review scenario that puts all three settings together.
A teammate opens a PR that adds export const runtime = 'edge' to a route handler. The handler runs a Drizzle query against your pooled Postgres connection, and the PR note reads “faster cold starts.” What’s the right review comment?
proxy.ts so the query itself runs at the edge.maxConcurrency in vercel.json so the edge instance can absorb the extra traffic.proxy.ts doesn’t help — proxy.ts is Node.js-only too in Next.js 16, and database queries don’t belong in the proxy regardless — and there’s no maxConcurrency knob to turn, since in-function concurrency is automatic. This is exactly the “switch to Edge for speed” reflex a good review catches.A second scenario, on the setting that fails silently.
Right after launch, your p95 latency is much higher than it was in development, even though no code changed near any database query and every test still passes. What’s the first thing to check?
External resources
Section titled “External resources”The authoritative source on the automatic in-function concurrency model, isolation boundaries, and the per-plan duration limits.
Node.js versus Edge, plus the default iad1 region — Vercel's own comparison of the two runtimes.
The middleware-to-proxy rename and the Node.js-runtime-only fact, for when you hit older docs that still say 'middleware is Edge.'
The official AsyncLocalStorage reference — how per-request context travels a call stack without leaking between concurrent requests.