Fix: Vercel Edge Function Not Working — Runtime APIs, Bundle Size, DB Drivers, and Middleware
Part of: React & Frontend Errors
Quick Answer
How to fix Vercel Edge Function errors — Node.js APIs not available in Edge Runtime, 1MB bundle size limit, Postgres/MySQL drivers incompatible, streaming responses, geo headers, and Middleware vs Edge API Route.
The Error
You move a function to the Edge runtime and it fails with API errors:
[Error] The Edge Runtime does not support Node.js 'fs' module.
Learn more: https://nextjs.org/docs/messages/node-module-in-edge-runtimeOr the build fails with size limits:
Error: The Edge Function "api/hello" size is 2.3 MB and your plan size limit is 1 MB.Or your Postgres client throws at runtime:
ReferenceError: tls is not definedOr request.geo is undefined locally:
export default function handler(request: NextRequest) {
console.log(request.geo); // undefined locally; works on Vercel.
}Why This Happens
Vercel’s Edge Runtime is a V8 isolate environment based on Web Standards (Fetch API, Web Streams, Web Crypto). It’s not Node.js. Three core constraints:
- No Node.js APIs. No
fs,path,child_process,net. Mostcryptois available via Web Crypto. Anything using TCP sockets (raw DB drivers) breaks. - Bundle size limits. Edge Functions have a strict bundle ceiling (1 MB on Hobby, 4 MB on Pro/Enterprise after compression). Big dependencies don’t fit.
- Code runs at the edge. Cold start is faster than Lambda (~50ms vs 200-1000ms) but each region has its own instance. Stateful patterns that worked in single-region Lambda don’t fit.
For DB access from Edge, you need:
- A driver that uses HTTP or WebSocket, not raw TCP (e.g.
@neondatabase/serverless,@vercel/postgres, Cloudflare D1 client). - Or a database that exposes an HTTP API (PlanetScale, Turso, Supabase REST).
Diagnostic Timeline
The same scenario plays out on most Edge migrations.
Minute 0. The function 500s with The Edge Runtime does not support Node.js 'fs' module. First guess: “increase the memory limit.” The Vercel dashboard does not even expose a memory slider for Edge Functions — memory is fixed at 128 MB and the error has nothing to do with size.
Minute 3. You search the codebase for fs.readFile. None of your code calls it. The next clue is the stack trace pointing to a dependency in node_modules. A logging library, a markdown loader, or an auth helper is reaching for fs.readFileSync at import time.
Minute 11. You try import("fs/promises") lazily so it only runs when needed. That works on Node but the bundler still includes the module graph in the Edge bundle. You either replace the dependency or ship the handler on runtime = "nodejs".
Minute 18. The next deploy fails with The Edge Function "api/llm" size is 2.3 MB and your plan size limit is 1 MB. You assume tree-shaking is broken. It is not — the AI SDK, a tokenizer, and a Zod schema together exceed 1 MB compressed.
Minute 27. You run ANALYZE=true next build. The single biggest offender is a 380 KB tokenizer pulled in transitively. You swap to a streamed tokenizer in the AI SDK that defers loading until request time, and the bundle drops below the limit.
Minute 34. Your Postgres handler now deploys but throws ReferenceError: tls is not defined at request time. The pg driver opens TCP sockets, which the Edge Runtime does not expose. You swap to @neondatabase/serverless or move the handler back to Node.
Minute 42. A streaming SSE endpoint times out in 25 seconds even though the LLM takes 40. The Hobby plan caps Edge wall-clock at 25s regardless of streaming. Upgrade to Pro or move long-running streams to a Node runtime function with maxDuration = 60.
The Edge Runtime also enforces a hard CPU-time ceiling per request that is much tighter than Lambda’s wall-clock timeout. You get roughly 50ms of CPU time on the free tier and a few hundred ms on Pro — not wall-clock. Awaiting fetch is free; tight JavaScript loops, JSON parsing of multi-megabyte payloads, and synchronous crypto over large buffers eat the budget fast. A function that runs fine on Node will sporadically 504 at the edge purely because the CPU clock keeps ticking during inlined work that doesn’t yield to the event loop.
Region affinity is the other quiet failure mode. Edge Functions execute in the Vercel POP closest to the user, but each POP boots its own isolate. A handler that lazily initializes a global like let cache: Map = new Map() will warm caches independently in every region — so the first hit per region will always be slow, and you cannot rely on warm state for rate limiting or deduplication. Use Edge Config, Vercel KV, or a single-region serverless function for state that must be globally consistent.
Fix 1: Declare the Edge Runtime
For Next.js App Router:
// app/api/hello/route.ts
export const runtime = "edge";
export async function GET(request: Request) {
return Response.json({ hello: "world" });
}For Next.js Pages Router:
// pages/api/hello.ts
export const config = {
runtime: "edge",
};
export default function handler(request: Request) {
return Response.json({ hello: "world" });
}For Next.js Middleware:
// middleware.ts
import { NextResponse } from "next/server";
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith("/admin")) {
return NextResponse.redirect(new URL("/login", request.url));
}
}
export const config = {
matcher: "/admin/:path*",
};Middleware always runs on the Edge Runtime — you can’t switch it to Node.
Pro Tip: Use runtime = "nodejs" (the default) for anything that needs fs, native crypto, or large dependencies. Reserve Edge for low-latency, geo-distributed, lightweight handlers.
Fix 2: Find Node API Replacements
Common substitutions:
| Node API | Edge equivalent |
|---|---|
fs.readFile | Bundle files as imports or use a remote fetch |
crypto.createHash | crypto.subtle.digest (Web Crypto) |
crypto.randomUUID | crypto.randomUUID() (also Web standard) |
crypto.randomBytes | crypto.getRandomValues(new Uint8Array(N)) |
Buffer.from(str) | new TextEncoder().encode(str) for bytes |
Buffer.toString("base64") | btoa(...) for ASCII, Buffer.from(...).toString("base64") via polyfill |
process.env | Available — but only env vars allowlisted for the Edge Runtime |
setTimeout(fn, ms) | Available (Web standard) |
path.join | Manual string concat or import from a small polyfill |
For SHA-256 hashing:
async function sha256(text: string): Promise<string> {
const data = new TextEncoder().encode(text);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
return [...new Uint8Array(hashBuffer)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}For HMAC signing:
async function hmacSign(secret: string, message: string): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message));
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
}Common Mistake: Importing a library that does require("fs") even when you don’t call that code path. The bundler may include it in the Edge bundle and the runtime errors at startup. Check your imports — packages that don’t ship with conditional ESM exports often include Node-only code.
Fix 3: Use Edge-Compatible DB Drivers
Drivers that work on Edge (HTTP/WebSocket-based):
// Neon (Postgres):
import { neon } from "@neondatabase/serverless";
export const runtime = "edge";
export async function GET() {
const sql = neon(process.env.DATABASE_URL!);
const rows = await sql`SELECT * FROM users LIMIT 10`;
return Response.json(rows);
}// PlanetScale (MySQL):
import { connect } from "@planetscale/database";
export const runtime = "edge";
export async function GET() {
const conn = connect({ url: process.env.DATABASE_URL });
const result = await conn.execute("SELECT * FROM users LIMIT 10");
return Response.json(result.rows);
}// Turso (libSQL/SQLite at the edge):
import { createClient } from "@libsql/client";
const client = createClient({
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_AUTH_TOKEN!,
});
export const runtime = "edge";// Vercel Postgres (wraps Neon):
import { sql } from "@vercel/postgres";
export const runtime = "edge";
export async function GET() {
const { rows } = await sql`SELECT * FROM users LIMIT 10`;
return Response.json(rows);
}Drivers that don’t work on Edge:
pg(Postgres) — uses TCP/TLS sockets.mysql2— TCP-based.mongodb(Node driver) — TCP-based.ioredis— TCP-based.- ORMs that wrap them (
prismawithout@prisma/adapter-neon,typeorm,sequelize).
For Prisma:
import { PrismaClient } from "@prisma/client";
import { PrismaNeon } from "@prisma/adapter-neon";
import { Pool } from "@neondatabase/serverless";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaNeon(pool);
const prisma = new PrismaClient({ adapter });
export const runtime = "edge";Prisma 5.10+ has Edge-compatible adapters for Neon, PlanetScale, D1, and Turso.
Fix 4: Keep Bundles Small
The 1 MB / 4 MB limit is on compressed bundle size. Strategies:
Avoid large dependencies. Some Node-style libs include Node-only fallbacks that bundlers can’t tree-shake. Check next build output for the worst offenders.
Use lighter alternatives:
// Heavy: zod (~13 KB)
import { z } from "zod";
// Lighter: valibot (~2-3 KB)
import * as v from "valibot";
// Heavy: lodash
import _ from "lodash"; // Includes all of lodash
// Lighter: native or scoped
const groupBy = <T, K extends string>(arr: T[], key: (item: T) => K) =>
arr.reduce((acc, item) => {
const k = key(item);
(acc[k] ||= []).push(item);
return acc;
}, {} as Record<K, T[]>);Dynamic import for rarely-used branches:
if (request.headers.get("x-debug")) {
const { debug } = await import("./debug-helper");
return debug(request);
}The debug helper isn’t in the main bundle.
Check the Vercel build output:
Route (app) Size First Load JS
┌ ƒ /api/edge 2.34 MB ❌ Too bigIf a route reports ƒ (Edge) and a size over the limit, the build fails on deploy.
Pro Tip: Use @next/bundle-analyzer to find what’s in the bundle:
const withBundleAnalyzer = require("@next/bundle-analyzer")({ enabled: true });
module.exports = withBundleAnalyzer(nextConfig);ANALYZE=true npm run buildFix 5: Streaming Responses
Edge functions support Server-Sent Events and streaming naturally:
export const runtime = "edge";
export async function GET() {
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
for (let i = 0; i < 10; i++) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ count: i })}\n\n`));
await new Promise((r) => setTimeout(r, 1000));
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}For AI streaming (LLM token-by-token):
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
export const runtime = "edge";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({ model: openai("gpt-4o-mini"), messages });
return result.toDataStreamResponse();
}ai SDK works at the Edge by design — built around Web Streams.
Note: Vercel’s free tier caps Edge Function execution time at 25 seconds. For longer streams (long-running LLM responses), upgrade or move to Lambda.
Fix 6: Geolocation and Headers
Geo data comes via headers (x-vercel-ip-country, etc.) or request.geo (Next.js):
import type { NextRequest } from "next/server";
export const runtime = "edge";
export default function handler(request: NextRequest) {
const country = request.geo?.country ?? "unknown";
const region = request.geo?.region ?? "";
const city = request.geo?.city ?? "";
return Response.json({ country, region, city });
}For raw headers (any framework):
const country = request.headers.get("x-vercel-ip-country");
const ip = request.headers.get("x-real-ip") ?? request.headers.get("x-forwarded-for");Geo data is populated only on Vercel. Locally during next dev, request.geo is empty. Test with a deploy preview.
Common Mistake: Relying on geo for security checks (e.g. “block users from country X”). VPNs and CDN routing can make geo unreliable. Use it for UX (default language, currency), not for authorization.
Fix 7: Edge Config for Fast Reads
@vercel/edge-config provides a read-optimized key-value store:
import { get } from "@vercel/edge-config";
export const runtime = "edge";
export async function GET() {
const flag = await get<boolean>("show_banner");
return Response.json({ flag });
}Reads are ~1ms from any edge location. Useful for:
- Feature flags
- A/B test assignments
- Site config (banner copy, allowed origins, etc.)
- IP/country blocklists
Edge Config has size limits (~512 KB) and writes are slower than reads — design for “read often, write rarely.”
For larger data, use KV (Vercel KV or external Redis); for arbitrary blob data, Vercel Blob.
Fix 8: Middleware vs Edge API Route
Three Edge-runtime placements in Next.js:
- Middleware (
middleware.ts) — runs before every matched route. Cannot return a body for non-matched routes. Best for redirects, headers, auth gating. - Edge API Route (
app/api/.../route.tswithruntime = "edge") — a full request handler at the edge. Returns any response. - Server Component with edge runtime — page renders at the edge. Set via
export const runtime = "edge"on the page file.
Don’t use Middleware for heavy logic:
// middleware.ts — BAD: runs on every request
export async function middleware(request: NextRequest) {
const user = await fetchUserFromDB(); // DB call per request — slow
// ...
}
// Better: do it in Edge API route or page server component.Middleware should be ~10ms or less. Heavy work goes in Edge API routes (still fast, but you’re not blocking page load).
Pro Tip: Use matcher in middleware config to scope what runs. Without it, middleware runs on every request including static assets:
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico).*)",
],
};The regex excludes static asset paths from middleware.
Still Not Working?
A few less-obvious failures:
process is not definedfor some env vars. Only env vars marked accessible to Edge are populated. Set them via Vercel dashboard, not.env.localalone.structuredClonenot available. Older Edge runtimes lack it. UseJSON.parse(JSON.stringify(...))or upgrade Node.js version target innext.config.js.Date.nowreturns different values across regions. Each edge region has its own clock. For consistent timestamps in distributed counting, use a single source of truth (DB timestamp).fetchis the global, not Node’s. Headers are set differently;node-fetchpatterns may fail. Use standardfetchsyntax.- Bundle includes unused imports. Tree-shaking may not work for some libs. Move imports inside functions to defer loading.
URLconstructor behaves differently. Edge runtime URL spec is web-standard, not Node’s slightly looser version. Some quirks like trailing slashes differ.- Cookies via
cookies()are read-only in middleware. Set them on the response:
const response = NextResponse.next();
response.cookies.set("session", token, { httpOnly: true });
return response;Reactserver-render fails at Edge. Some React features (Suspense streaming) work; others need adjustments. Test withpnpm buildlocally before deploying.- CPU-time budget exceeded with no clear error. The function returns a generic 504 with no stack trace. Wrap every synchronous loop with
await Promise.resolve()periodically to yield, or move CPU-heavy work to a Node runtime function. - Edge function reads
process.cwd(). Returns the runtime directory at the edge POP, not your project root. Bundled assets should beimported (so they end up in the bundle) rather than read from disk. AbortSignal.timeoutnot respected by some libraries. The Edge Runtime supportsAbortSignal.timeout(ms)but older fetch wrappers and SDKs ignore it. Pass the signal explicitly to every downstream call.
For related serverless and Next.js issues, see Vercel deployment failed, Next.js middleware not running, AWS Lambda cold start timeout, and Next.js app router fetch cache.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Vercel Blob Not Working — put/get/del, handleUpload Browser Flow, Access Modes, and Multipart
How to fix Vercel Blob errors — BLOB_READ_WRITE_TOKEN missing, put vs handleUpload for browser, public vs private access, multipart upload for large files, expires/signed URLs, list/cursor pagination, and overwriting URLs.
Fix: Next.js 15 cookies() Should Be Awaited — Route Used cookies, Cannot Modify Errors, and Library Mismatch
Fix Next.js 15 async cookies() and headers() errors — 'Route used cookies', 'Cookies can only be modified in a Server Action or Route Handler', codemod misses, library compatibility, and TypeScript type mismatches after upgrade.
Fix: AWS Lambda SnapStart Not Working — Version vs Alias, Restore Hooks, and Uniqueness Bugs
How to fix Lambda SnapStart errors — feature requires published version, $LATEST not supported, restore hook for stale connections, UUID collisions after snapshot, time-based state staleness, and pricing surprises.
Fix: AWS Step Functions Not Working — ASL Syntax, Map State, Error Handling, and IAM
How to fix AWS Step Functions errors — Amazon States Language syntax, Standard vs Express workflows, Distributed Map for large datasets, Retry/Catch error handling, Lambda invoke optimization, and IAM execution role permissions.