DEV Community

HK Lee
HK Lee

Posted on • Originally published at pockit.tools

Edge Runtime vs Node.js Runtime: When Your Serverless Functions Mysteriously Fail

Edge Runtime vs Node.js Runtime: When Your Serverless Functions Mysteriously Fail

You've seen the promise: Edge functions are faster, cheaper, and run closer to your users. Cold starts measured in milliseconds instead of seconds. Global distribution by default. So you add export const runtime = 'edge' to your Next.js API route, deploy, and... everything breaks.

Welcome to the Edge Runtime debugging hell.

The error messages are cryptic. "Dynamic code evaluation not supported." "Module not found." "This API is not available." Your code worked perfectly in development. It worked in the Node.js runtime. But Edge? Edge has opinions about what you can and cannot do.

This isn't a rant against Edge Runtime—it's genuinely powerful technology. But there's a massive knowledge gap between "Edge is fast" marketing and the brutal reality of making production code run on it. This guide will bridge that gap with hard-won debugging knowledge.

The Fundamental Difference: What Edge Runtime Actually Is

Before we debug, we need to understand what we're working with. Edge Runtime and Node.js Runtime aren't just different locations where your code runs—they're fundamentally different execution environments with different APIs, different constraints, and different mental models.

Node.js Runtime: The Kitchen Sink

Node.js runtime is what you're familiar with. It's a full Node.js environment running on a server:

// This works in Node.js runtime
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { spawn } from 'child_process';

export async function POST(request) {
  // Read from filesystem
  const config = JSON.parse(fs.readFileSync('./config.json', 'utf8'));

  // Use native crypto
  const hash = crypto.createHash('sha256').update('secret').digest('hex');

  // Spawn child processes
  const child = spawn('ls', ['-la']);

  // Use any npm package
  const pdf = await generatePDF(data);  // Uses native bindings

  return Response.json({ success: true });
}
Enter fullscreen mode Exit fullscreen mode

Node.js runtime gives you:

  • Full access to Node.js core modules (fs, path, crypto, child_process, etc.)
  • Native module support (C++ addons, Rust bindings via NAPI)
  • No code size limits (practically speaking)
  • Long execution timeouts (up to 5 minutes on most platforms)
  • Connection to databases via native drivers
  • Familiar debugging with full stack traces

The tradeoff? Cold starts of 250ms-1000ms+, regional deployment (not global by default), and higher costs at scale.

Edge Runtime: The Stripped-Down Sprinter

Edge Runtime is a completely different beast. It's based on Web APIs and V8 isolates—the same technology that powers Cloudflare Workers:

// This is all you have in Edge Runtime
export const runtime = 'edge';

export async function POST(request) {
  // Web Fetch API - yes
  const response = await fetch('https://api.example.com/data');

  // Web Crypto API - yes (but different from Node crypto!)
  const hashBuffer = await crypto.subtle.digest('SHA-256', 
    new TextEncoder().encode('secret')
  );

  // Headers, Request, Response - yes
  const headers = new Headers();
  headers.set('Cache-Control', 'max-age=3600');

  // TextEncoder/TextDecoder - yes
  const encoded = new TextEncoder().encode('hello');

  return new Response(JSON.stringify({ success: true }), { headers });
}
Enter fullscreen mode Exit fullscreen mode

Edge Runtime gives you:

  • Web Platform APIs only (Fetch, Streams, Crypto, URL, etc.)
  • Sub-10ms cold starts
  • Global distribution (runs in 300+ locations)
  • Lower costs at high scale
  • Strict 1-4MB code size limits
  • Short execution timeouts (typically 30 seconds max)

The tradeoff? No filesystem, no native modules, no Node.js core modules, and a much smaller subset of npm packages that actually work.

The Failure Taxonomy: Why Your Code Breaks

Now let's categorize the failures you'll encounter. Understanding the type of failure is the first step to fixing it.

Category 1: Missing Node.js Core Modules

The most common failure. You import something that doesn't exist in Edge:

// ❌ These will fail in Edge Runtime
import fs from 'fs';                    // No filesystem
import path from 'path';                // No path module  
import crypto from 'crypto';            // Wrong crypto API
import { Buffer } from 'buffer';        // Limited Buffer support
import stream from 'stream';            // No Node streams
import http from 'http';                // No http module
import https from 'https';              // No https module
import net from 'net';                  // No TCP sockets
import dns from 'dns';                  // No DNS lookups
import child_process from 'child_process';  // No processes
import os from 'os';                    // No OS info
import worker_threads from 'worker_threads';  // No threads
Enter fullscreen mode Exit fullscreen mode

The fix: Replace with Web API equivalents or polyfills:

// ✅ Edge Runtime equivalents

// Instead of crypto.createHash()
async function sha256(message) {
  const msgBuffer = new TextEncoder().encode(message);
  const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}

// Instead of Buffer.from()
function base64Encode(str) {
  return btoa(String.fromCharCode(...new TextEncoder().encode(str)));
}

function base64Decode(base64) {
  return new TextDecoder().decode(
    Uint8Array.from(atob(base64), c => c.charCodeAt(0))
  );
}

// Instead of path.join()
function joinPath(...segments) {
  return segments.join('/').replace(/\/+/g, '/');
}

// Instead of URL parsing with querystring
function parseQuery(url) {
  return Object.fromEntries(new URL(url).searchParams);
}
Enter fullscreen mode Exit fullscreen mode

Category 2: Dynamic Code Evaluation

Edge Runtime prohibits eval() and new Function() for security reasons. This breaks more packages than you'd expect:

// ❌ These all fail in Edge Runtime
eval('console.log("hello")');
new Function('return 1 + 1')();
require('vm').runInNewContext('1 + 1');  // Also no vm module

// Many packages use these internally:
// - Some template engines (Handlebars, EJS in certain modes)
// - Some schema validators
// - Some serialization libraries
// - Source map processing
Enter fullscreen mode Exit fullscreen mode

The error message:

Dynamic code evaluation (e.g., 'eval', 'new Function', 'WebAssembly.compile') 
not allowed in Edge Runtime
Enter fullscreen mode Exit fullscreen mode

The fix: Find alternative packages or configure libraries to avoid dynamic evaluation:

// Instead of lodash template with dynamic evaluation
// ❌ This uses new Function internally
import template from 'lodash/template';
const compiled = template('Hello <%= name %>');

// ✅ Use a static template library
import Mustache from 'mustache';
const output = Mustache.render('Hello {{name}}', { name: 'World' });

// Or use tagged template literals
function html(strings, ...values) {
  return strings.reduce((result, str, i) => 
    result + str + (values[i] ?? ''), ''
  );
}
const name = 'World';
const output = html`Hello ${name}`;
Enter fullscreen mode Exit fullscreen mode

Category 3: Native Module Dependencies

Any package that uses native C++/Rust bindings will fail on Edge:

// ❌ These packages won't work in Edge
import sharp from 'sharp';              // Image processing (native)
import bcrypt from 'bcrypt';            // Password hashing (native)
import canvas from 'canvas';            // Canvas rendering (native)
import sqlite3 from 'sqlite3';          // SQLite (native)
import puppeteer from 'puppeteer';      // Browser automation (native)
import prisma from '@prisma/client';    // Prisma (native query engine)
Enter fullscreen mode Exit fullscreen mode

The error messages:

Error: Cannot find module 'sharp'
Module build failed: Native modules are not supported in Edge Runtime
Enter fullscreen mode Exit fullscreen mode

The fix: Use Edge-compatible alternatives:

// ✅ Edge-compatible alternatives

// Instead of bcrypt (native)
import { hash, compare } from 'bcryptjs';  // Pure JS implementation

// Instead of sharp (native)
// Use a cloud image service or WebAssembly-based solution
async function resizeImage(imageUrl, width, height) {
  const response = await fetch(
    `https://images.example.com/resize?url=${encodeURIComponent(imageUrl)}&w=${width}&h=${height}`
  );
  return response;
}

// Instead of Prisma with native bindings
// Use Prisma's Edge-compatible driver adapters
import { PrismaClient } from '@prisma/client';
import { PrismaNeon } from '@prisma/adapter-neon';

// Or use serverless-friendly ORMs
import { drizzle } from 'drizzle-orm/neon-http';

// Instead of SQLite
// Use D1 (Cloudflare), Turso, or PlanetScale
Enter fullscreen mode Exit fullscreen mode

Category 4: Synchronous Operations That Block

Edge Runtime is designed for fast, non-blocking operations. Anything that blocks the event loop is problematic:

// ❌ These patterns cause issues in Edge
import { readFileSync } from 'fs';  // Sync file read (also: no fs)
sleep(1000);  // Blocking sleep

// Heavy synchronous computation
function fibonacciSync(n) {
  if (n <= 1) return n;
  return fibonacciSync(n - 1) + fibonacciSync(n - 2);
}
const result = fibonacciSync(45);  // Blocks for seconds

// Synchronous JSON parsing of huge payloads
const hugeData = JSON.parse(hugeJsonString);  // Can timeout
Enter fullscreen mode Exit fullscreen mode

The fix: Use streaming and chunked processing:

// ✅ Edge-friendly patterns

// Stream large responses instead of buffering
export async function GET() {
  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < 1000; i++) {
        const chunk = await fetchChunk(i);
        controller.enqueue(new TextEncoder().encode(JSON.stringify(chunk) + '\n'));
        // Allow other operations to proceed
        await new Promise(resolve => setTimeout(resolve, 0));
      }
      controller.close();
    }
  });

  return new Response(stream, {
    headers: { 'Content-Type': 'application/x-ndjson' }
  });
}

// Use Web Streams for parsing
async function parseJsonStream(response) {
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    // Process complete lines
    const lines = buffer.split('\n');
    buffer = lines.pop(); // Keep incomplete line in buffer

    for (const line of lines) {
      if (line.trim()) {
        yield JSON.parse(line);
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Category 5: Size Limit Violations

Edge functions have strict size limits. Vercel Edge Functions: 1MB (free) to 4MB (Pro). Cloudflare Workers: 1MB (free) to 10MB (paid):

// ❌ These blow up your bundle size
import _ from 'lodash';              // 70KB+ minified
import moment from 'moment';          // 300KB+ with locales
import * as AWS from 'aws-sdk';       // Massive
import 'core-js/stable';              // 150KB+ of polyfills

// Importing entire icon libraries
import * as Icons from '@heroicons/react/24/solid';

// Bundled ML models or large datasets
import model from './large-ml-model.json';  // 2MB model
Enter fullscreen mode Exit fullscreen mode

The fix: Tree shake aggressively and lazy load:

// ✅ Size-optimized imports

// Instead of full lodash
import groupBy from 'lodash/groupBy';
import debounce from 'lodash/debounce';

// Instead of moment
import { format, parseISO } from 'date-fns';

// Instead of aws-sdk v2
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';

// Specific icon imports
import { HomeIcon } from '@heroicons/react/24/solid';

// Fetch large data at runtime instead of bundling
export async function GET() {
  const model = await fetch('https://cdn.example.com/model.json').then(r => r.json());
  // Use model...
}
Enter fullscreen mode Exit fullscreen mode

Real-World Debugging Scenarios

Let's walk through actual debugging sessions for common Edge Runtime failures.

Scenario 1: The Cryptic "Module Not Found"

Symptom: Your app works locally, builds successfully, but fails at runtime in Edge.

Error: Cannot find module 'util'
    at EdgeRuntime (edge-runtime.js:1:1)
Enter fullscreen mode Exit fullscreen mode

Diagnosis process:

// Step 1: Identify what's importing 'util'
// Check your dependencies recursively

// package.json dependencies might look fine
{
  "dependencies": {
    "next": "14.0.0",
    "jsonwebtoken": "9.0.0",  // <-- This is the culprit!
    "next-auth": "4.24.0"
  }
}

// Step 2: Check the dependency tree
// Run: npm ls util
// Output:
// └─┬ jsonwebtoken@9.0.0
//   └── util@0.12.5

// jsonwebtoken uses Node's util module internally!
Enter fullscreen mode Exit fullscreen mode

The fix:

// Option 1: Use an Edge-compatible JWT library
import { SignJWT, jwtVerify } from 'jose';  // Edge-compatible!

export const runtime = 'edge';

export async function POST(request) {
  const secret = new TextEncoder().encode(process.env.JWT_SECRET);

  // Sign
  const token = await new SignJWT({ userId: '123' })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime('1h')
    .sign(secret);

  // Verify
  const { payload } = await jwtVerify(token, secret);

  return Response.json({ token, payload });
}

// Option 2: Don't use Edge for this route
// Remove: export const runtime = 'edge';
// Let it run on Node.js runtime instead
Enter fullscreen mode Exit fullscreen mode

Scenario 2: The NextAuth.js Session Disaster

Symptom: Auth works in development, breaks in production with Edge.

Error: next-auth requires a secret to be set in production
Enter fullscreen mode Exit fullscreen mode

or

Error: [next-auth]: `useSession` must be wrapped in a <SessionProvider />
// (but it IS wrapped, and it worked yesterday!)
Enter fullscreen mode Exit fullscreen mode

Diagnosis:

// The issue: next-auth's route handlers aren't fully Edge-compatible
// app/api/auth/[...nextauth]/route.js

import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';

export const runtime = 'edge';  // ❌ This breaks things!

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [GitHub],
  // Database adapters also fail on Edge
  adapter: PrismaAdapter(prisma),  // ❌ Native dependencies!
});
Enter fullscreen mode Exit fullscreen mode

The fix:

// app/api/auth/[...nextauth]/route.js

import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';

// Option 1: Use Node.js runtime for auth (recommended for now)
export const runtime = 'nodejs';  // ✅ Explicit Node.js

// Option 2: Use Edge-compatible adapters
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { drizzle } from 'drizzle-orm/neon-http';

export const { handlers, auth } = NextAuth({
  providers: [GitHub],
  adapter: DrizzleAdapter(drizzle(process.env.DATABASE_URL)),  // ✅ Edge-compatible
});

// Middleware CAN use Edge for auth checks (reading, not writing)
// middleware.js
export { auth as middleware } from './auth';
export const config = { matcher: ['/dashboard/:path*'] };
Enter fullscreen mode Exit fullscreen mode

Scenario 3: The Database Connection Nightmare

Symptom: Database queries work locally, fail in Edge production.

Error: Connection pool exhausted
Error: prepared statement "s0" already exists
Error: Cannot establish database connection
Enter fullscreen mode Exit fullscreen mode

Diagnosis:

// Traditional database connections don't work on Edge
// The problem: Edge functions are stateless and globally distributed

import { Pool } from 'pg';  // ❌ Connection pooling breaks on Edge

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,  // This makes no sense on Edge
});

// Each Edge invocation is isolated - no shared connection pool!
// You're trying to create connections from 300+ global locations
// to a single database region - recipe for disaster
Enter fullscreen mode Exit fullscreen mode

The fix:

// ✅ Edge-compatible database patterns

// Option 1: HTTP-based database connections (best for Edge)
import { neon } from '@neondatabase/serverless';

export const runtime = 'edge';

export async function GET() {
  const sql = neon(process.env.DATABASE_URL);

  // Each query is a separate HTTP request - no connection to manage
  const users = await sql`SELECT * FROM users LIMIT 10`;

  return Response.json({ users });
}

// Option 2: Prisma with Data Proxy
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient({
  datasources: {
    db: {
      url: process.env.PRISMA_DATA_PROXY_URL,  // Use data proxy, not direct connection
    },
  },
});

// Option 3: For heavy database workloads, don't use Edge
// route.js
export const runtime = 'nodejs';  // Accept the cold start tradeoff

import { db } from '@/lib/database';

export async function GET() {
  const users = await db.user.findMany();
  return Response.json({ users });
}
Enter fullscreen mode Exit fullscreen mode

The Decision Framework: When to Use Each Runtime

After all this debugging pain, you might be wondering: when should I actually use Edge Runtime?

Use Edge Runtime When:

1. Low latency is critical and data is cacheable:

// ✅ Perfect for Edge: Geographic routing, cached content
export const runtime = 'edge';

export async function GET(request) {
  const country = request.geo?.country || 'US';

  // Serve region-specific content from cache
  const content = await fetch(`https://cdn.example.com/content/${country}.json`, {
    next: { revalidate: 3600 }
  });

  return Response.json(await content.json());
}
Enter fullscreen mode Exit fullscreen mode

2. Authentication checks (read-only):

// ✅ Perfect for Edge: Validating JWTs, checking permissions
export const runtime = 'edge';

export async function middleware(request) {
  const token = request.cookies.get('session');

  if (!token) {
    return Response.redirect(new URL('/login', request.url));
  }

  try {
    await jwtVerify(token.value, secret);
    return NextResponse.next();
  } catch {
    return Response.redirect(new URL('/login', request.url));
  }
}
Enter fullscreen mode Exit fullscreen mode

3. A/B testing and feature flags:

// ✅ Perfect for Edge: Instant decisions, no backend call needed
export const runtime = 'edge';

export async function middleware(request) {
  const bucket = Math.random();
  const variant = bucket < 0.5 ? 'control' : 'treatment';

  const response = NextResponse.next();
  response.cookies.set('experiment_variant', variant);

  return response;
}
Enter fullscreen mode Exit fullscreen mode

4. Simple transformations and redirects:

// ✅ Perfect for Edge: URL rewriting, header manipulation
export const runtime = 'edge';

export async function middleware(request) {
  const url = request.nextUrl.clone();

  // Redirect old URLs
  if (url.pathname.startsWith('/old-blog/')) {
    url.pathname = url.pathname.replace('/old-blog/', '/blog/');
    return Response.redirect(url);
  }

  // Add security headers
  const response = NextResponse.next();
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');

  return response;
}
Enter fullscreen mode Exit fullscreen mode

Use Node.js Runtime When:

1. Database operations with connection pooling:

// ✅ Node.js: Traditional databases that need persistent connections
export const runtime = 'nodejs';

import { prisma } from '@/lib/prisma';

export async function GET() {
  const users = await prisma.user.findMany({
    include: { posts: true },
    take: 50,
  });

  return Response.json({ users });
}
Enter fullscreen mode Exit fullscreen mode

2. File operations or binary processing:

// ✅ Node.js: Anything touching filesystem or native binaries
export const runtime = 'nodejs';

import sharp from 'sharp';
import { writeFile } from 'fs/promises';

export async function POST(request) {
  const formData = await request.formData();
  const file = formData.get('image');

  const buffer = Buffer.from(await file.arrayBuffer());
  const processed = await sharp(buffer)
    .resize(800, 600)
    .webp({ quality: 80 })
    .toBuffer();

  await writeFile(`./uploads/${file.name}.webp`, processed);

  return Response.json({ success: true });
}
Enter fullscreen mode Exit fullscreen mode

3. Complex business logic with many dependencies:

// ✅ Node.js: When you need the full npm ecosystem
export const runtime = 'nodejs';

import Stripe from 'stripe';
import { sendEmail } from '@/lib/email';  // Uses nodemailer
import { generatePDF } from '@/lib/pdf';  // Uses puppeteer
import { prisma } from '@/lib/prisma';

export async function POST(request) {
  const order = await request.json();

  // Process payment
  const stripe = new Stripe(process.env.STRIPE_SECRET);
  const payment = await stripe.paymentIntents.create({...});

  // Update database
  await prisma.order.update({...});

  // Generate invoice PDF
  const pdf = await generatePDF(order);

  // Send confirmation email
  await sendEmail({
    to: order.email,
    subject: 'Order Confirmation',
    attachments: [{ filename: 'invoice.pdf', content: pdf }],
  });

  return Response.json({ success: true });
}
Enter fullscreen mode Exit fullscreen mode

4. Long-running operations:

// ✅ Node.js: When you need more than 30 seconds
export const runtime = 'nodejs';
export const maxDuration = 300;  // 5 minutes

export async function POST(request) {
  const { videoUrl } = await request.json();

  // Download and process video (takes 2-3 minutes)
  const processed = await processVideo(videoUrl);

  return Response.json({ 
    downloadUrl: processed.url,
    duration: processed.duration
  });
}
Enter fullscreen mode Exit fullscreen mode

The Hybrid Approach: Best of Both Worlds

The real answer isn't "Edge vs Node.js"—it's using both strategically:

src/
├── app/
│   ├── api/
│   │   ├── auth/         # Node.js - database sessions
│   │   ├── payments/     # Node.js - Stripe, complex logic
│   │   ├── upload/       # Node.js - file processing
│   │   ├── geo/          # Edge - location-based content
│   │   ├── flags/        # Edge - feature flags
│   │   └── health/       # Edge - fast health checks
│   └── middleware.ts     # Edge - auth checks, redirects
Enter fullscreen mode Exit fullscreen mode
// middleware.ts - Runs on Edge everywhere
import { auth } from './auth';

export default auth((request) => {
  // Fast, global auth checks
  if (!request.auth && request.nextUrl.pathname.startsWith('/dashboard')) {
    return Response.redirect(new URL('/login', request.url));
  }
});

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
Enter fullscreen mode Exit fullscreen mode
// app/api/geo/route.ts - Edge for fast geo lookups
export const runtime = 'edge';

export async function GET(request) {
  const { country, city, latitude, longitude } = request.geo || {};

  const nearestStore = await fetch(
    `https://api.stores.com/nearest?lat=${latitude}&lng=${longitude}`,
    { next: { revalidate: 3600 } }  // Cache for 1 hour
  ).then(r => r.json());

  return Response.json({ nearestStore, userLocation: { country, city } });
}
Enter fullscreen mode Exit fullscreen mode
// app/api/orders/route.ts - Node.js for complex operations
export const runtime = 'nodejs';

export async function POST(request) {
  // Full Node.js power for order processing
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Edge Runtime is powerful, but it's not magic. It's a constrained environment that trades full Node.js capabilities for global distribution and sub-10ms cold starts. Understanding these constraints—missing modules, no dynamic evaluation, no native binaries, size limits, and stateless execution—is essential for successful Edge deployment.

Key takeaways:

  1. Edge is for fast, simple operations — Auth checks, redirects, A/B tests, and cached content delivery. Not for complex business logic.

  2. When in doubt, use Node.js — The cold start penalty (250ms-1000ms) is often acceptable, and you get full API compatibility.

  3. Database strategy matters — HTTP-based connections (Neon, PlanetScale HTTP, Prisma Data Proxy) for Edge, connection pools for Node.js.

  4. Check your dependencies — Run npm ls on any Node core module to find which package is importing it. Replace with Edge-compatible alternatives.

  5. Size matters — Tree-shake aggressively, lazy-load data, and use specific imports instead of barrel files.

  6. Hybrid is the answer — Use Edge for your middleware and latency-critical routes, Node.js for everything else.

The next time your serverless function mysteriously fails after adding export const runtime = 'edge', you'll know exactly where to look. Edge Runtime isn't broken—it's just different. And once you understand those differences, you can leverage its power without falling into its traps.


💡 Note: This article was originally published on the Pockit Blog.

Check out Pockit.tools for 60+ free developer utilities. For faster access, add it to Chrome and use JSON Formatter & Diff Checker directly from your toolbar.

Top comments (0)