Skip to content
Chapter 98Lesson 3

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.

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.

User Browser sends a request
Vercel Function sfo1 San Francisco, US West
~80 ms each way
across the country
Neon Postgres iad1 Washington, D.C., US East
Function in sfo1, database in iad1: every query crosses the country and back.

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 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.

vercel.json
{
"$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.

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:

app/api/geo/route.ts
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 or Node.js for this route?

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.

Instance 1request A
exec
waiting on DB
exec
Instance 2request B
cold start
waiting on DB
exec
Instance 3request C
cold start
waiting on DB
exec
exec (CPU busy) waiting on DB (idle)
time →
Classic serverless: one request per instance. Two of these three concurrent requests pay a cold start, and each instance sits idle waiting on the database.
Instance 1A · B · C
cold start
A
A
B
B
C
C
A exec B exec C exec waiting on DB
time →
Fluid Compute: one warm instance, the same three requests. While request A waits on the database, the instance runs request B in that gap — idle time filled, not wasted.
Classic serverless 3 instances 2 cold starts
Fluid Compute 1 instance 0–1 cold starts
Fewer instances · fewer cold starts · lower latency · lower bill
— with zero code changes.
The payoff: fewer instances, fewer cold starts, lower latency, lower bill, 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.

app/api/summary/route.ts
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.

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.

Fine to share (module scope) Stateless or built for concurrent use
Must stay per-request Holds data specific to one request
The Drizzle / Neon pooled database client
A Stripe SDK client instance
A compiled regular expression used for validation
A frozen config object read at module top level
let currentUserId set from the incoming request
An in-memory Map caching the current request’s computed totals
A per-request requestId for log correlation

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?

This route depends on the pooled Postgres driver, which Edge can’t load — it would break the route — and even on the HTTP driver, a faster cold start wouldn’t be worth giving up the pooled connection here. Keep it on Node.js.
Approve it. Edge runs closer to the user, so any route is faster there.
Approve, but ask them to move the Drizzle query into proxy.ts so the query itself runs at the edge.
Approve, and ask them to bump maxConcurrency in vercel.json so the edge instance can absorb the extra traffic.

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?

Whether the function and the database ended up in the same datacenter — if they’re a continent apart, every query pays the trip on every request.
Whether Fluid Compute got switched off, since that’s the setting that keeps requests fast.
Whether a heavy npm package slipped into the bundle and slowed the function down.
Whether you forgot to opt the slow routes into the Edge runtime to bring the latency down.