Skip to content

Fix: Vercel Edge Function Not Working — Runtime APIs, Bundle Size, DB Drivers, and Middleware

FixDevs · (Updated: )

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

Or 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 defined

Or 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. Most crypto is 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 APIEdge equivalent
fs.readFileBundle files as imports or use a remote fetch
crypto.createHashcrypto.subtle.digest (Web Crypto)
crypto.randomUUIDcrypto.randomUUID() (also Web standard)
crypto.randomBytescrypto.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.envAvailable — but only env vars allowlisted for the Edge Runtime
setTimeout(fn, ms)Available (Web standard)
path.joinManual 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 (prisma without @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 big

If 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 build

Fix 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.ts with runtime = "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 defined for some env vars. Only env vars marked accessible to Edge are populated. Set them via Vercel dashboard, not .env.local alone.
  • structuredClone not available. Older Edge runtimes lack it. Use JSON.parse(JSON.stringify(...)) or upgrade Node.js version target in next.config.js.
  • Date.now returns 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).
  • fetch is the global, not Node’s. Headers are set differently; node-fetch patterns may fail. Use standard fetch syntax.
  • Bundle includes unused imports. Tree-shaking may not work for some libs. Move imports inside functions to defer loading.
  • URL constructor 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;
  • React server-render fails at Edge. Some React features (Suspense streaming) work; others need adjustments. Test with pnpm build locally 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 be imported (so they end up in the bundle) rather than read from disk.
  • AbortSignal.timeout not respected by some libraries. The Edge Runtime supports AbortSignal.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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles