50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
deploymentMay 25, 2026·8 min read

Edge Functions vs Serverless Functions: When to Use Which

Edge and serverless both run 'in the cloud', but they're not interchangeable. Here's how to pick the right one before you regret it at 2am.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

Edge Functions vs Serverless Functions: When to Use Which

We made this mistake early on: we put everything on the edge because it sounded fast and modern. Then we tried to connect to a Postgres database and spent an afternoon wondering why pg kept throwing connection errors in production. Turns out, edge functions and serverless functions are fundamentally different beasts, not just different flavors of the same thing.

Both let you run code without managing a server. Both scale automatically. But the execution environment, the cold start behavior, the available APIs, and the things you flat-out cannot do differ enough that choosing wrong will bite you. Let's go through it properly.

What Actually Runs Where

Serverless functions (like AWS Lambda, Vercel Serverless Functions, Netlify Functions) run in a single region. When a request comes in, a container spins up somewhere in us-east-1 or eu-west-1, runs your Node.js code, and responds. The runtime is full Node.js. You get the file system (ephemeral), native addons, and most npm packages work as expected.

Edge functions run at the CDN edge — potentially in 30, 50, 100+ locations around the world, as close to the user as possible. The catch: they don't run in Node.js. They run in a V8 isolate, the same engine as the browser but stripped down. No native Node.js APIs. No `fs`. No native bindings. Many npm packages that use Node.js internals just won't work. The runtime is closer to a service worker than a server.

Edge functions are fast because they're lightweight and close to the user. Serverless functions are powerful because they run full Node.js. You can't have both, so pick based on what you actually need.

Cold Starts: The Real Difference

Serverless functions have cold start problems. If your function hasn't been invoked recently, the container has to spin up, which takes anywhere from 100ms to a few seconds depending on your runtime and bundle size. Node.js is better than it used to be, but it's still real latency you'll see in production.

Edge functions spin up in single-digit milliseconds because V8 isolates are much lighter than containers. They don't need to bootstrap a full Node.js process. This is why Vercel Edge Middleware can run on every single request without people noticing it's there.

But here's the thing — cold starts matter less than you think for most use cases. If you have steady traffic, your serverless functions stay warm. If you're building something with spiky or low traffic, you can often tolerate a 200ms cold start. Don't optimize prematurely for this.

What Edge Functions Are Actually Good At

Edge functions shine when you need to run simple logic before serving a request, without touching a database or doing anything heavy. The use cases are more specific than people realize:

  • Authentication checks — reading a JWT from a cookie and redirecting unauthenticated users before they hit your origin
  • A/B testing — deciding which variant to show without a round trip to a server
  • Geolocation-based redirects — sending Romanian users to /ro or blocking certain regions
  • Request rewriting and header manipulation — adding security headers, rewriting paths, proxying requests
  • Bot detection — simple checks before the request hits your expensive origin
  • Feature flags — checking a flag and serving different content based on the result

Notice the pattern: these are all things that run fast, don't need a database, and benefit from running close to the user. Here's what Next.js middleware looks like in practice — this is the kind of thing edge is genuinely great for:

// middleware.ts — runs at the edge on every request
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const token = request.cookies.get('session')?.value
  const { pathname } = request.nextUrl

  // Protect dashboard routes without hitting a database
  if (pathname.startsWith('/dashboard')) {
    if (!token) {
      return NextResponse.redirect(new URL('/login', request.url))
    }

    // Decode JWT without a DB roundtrip
    try {
      // Edge-compatible JWT verification
      const payload = parseJWT(token) // simple base64 decode + check expiry
      if (!payload || payload.exp < Date.now() / 1000) {
        return NextResponse.redirect(new URL('/login', request.url))
      }
    } catch {
      return NextResponse.redirect(new URL('/login', request.url))
    }
  }

  // Geolocation redirect
  const country = request.geo?.country
  if (pathname === '/' && country === 'RO') {
    return NextResponse.redirect(new URL('/ro', request.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/dashboard/:path*', '/'],
}

What Edge Functions Cannot Do (And People Keep Trying)

This is where people run into walls. The V8 isolate environment means you lose a lot of Node.js ecosystem compatibility. Things that break at the edge:

  • Direct database connections — TCP connections don't work from edge functions. No pg, no mysql2, no Prisma with standard drivers
  • Node.js crypto APIs — you have Web Crypto API instead, which works but has a different interface
  • File system access — no reading files, no writing logs to disk
  • Native Node.js modules — anything using `node:` imports or native bindings
  • Long-running operations — edge functions have tight CPU time limits (usually 30ms–50ms CPU time on Vercel)
  • Large npm packages — the bundle size limit is typically 1–4MB compressed depending on platform

The database one is the most painful. You can work around it using HTTP-based database clients — Neon has an HTTP driver, PlanetScale had one, Turso works over HTTP. But if you're using a standard Postgres driver expecting a TCP socket, you'll hit a runtime error that's confusing to debug the first time you see it.

// This will FAIL at the edge — pg uses TCP sockets
import { Pool } from 'pg'
const pool = new Pool({ connectionString: process.env.DATABASE_URL })

// This WORKS at the edge — Neon's HTTP driver
import { neon } from '@neondatabase/serverless'
const sql = neon(process.env.DATABASE_URL!)

export default async function handler(req: Request) {
  // This actually works from an edge function
  const users = await sql`SELECT id, email FROM users LIMIT 10`
  return Response.json(users)
}

Serverless Functions: The Workhorse

For most of your actual application logic — API routes, form handling, webhook processing, sending emails, database queries — you want serverless functions. They run full Node.js, so your entire npm ecosystem works. The tradeoff is that they run in one region, so a user in Sydney hitting your us-east-1 function gets 200–300ms of network latency before your code even runs.

In practice, this is fine for the vast majority of applications. Database calls, email sends, Stripe API calls — they all have latency too. Shaving 30ms by moving to the edge when you're also doing a 50ms database query doesn't change the user experience meaningfully.

// app/api/webhook/stripe/route.ts — this belongs in a serverless function
import { headers } from 'next/headers'
import Stripe from 'stripe'
import { db } from '@/lib/db'
import { sendEmail } from '@/lib/email'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(req: Request) {
  const body = await req.text()
  const signature = (await headers()).get('stripe-signature')!

  let event: Stripe.Event
  try {
    // stripe.webhooks.constructEvent needs Node.js crypto — works fine here
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    return new Response('Webhook signature verification failed', { status: 400 })
  }

  if (event.type === 'customer.subscription.created') {
    const subscription = event.data.object as Stripe.Subscription
    
    // Full database access, no restrictions
    await db.subscription.upsert({
      where: { stripeSubscriptionId: subscription.id },
      create: {
        stripeSubscriptionId: subscription.id,
        status: subscription.status,
        // ...
      },
      update: { status: subscription.status },
    })

    // Send email — needs nodemailer or similar, also Node.js only
    await sendEmail({
      to: 'user@example.com',
      subject: 'Your subscription is active',
    })
  }

  return new Response(null, { status: 200 })
}

Try to put this in an edge function and you'd be dealing with Node.js crypto compatibility issues for Stripe's webhook verification, Prisma not supporting the edge runtime without switching drivers, and nodemailer not running at all. It's not worth the fight.

The Decision Framework We Actually Use

We've simplified this into a quick mental checklist. When we're deciding where a piece of code runs, we ask these questions in order:

  • Does it need a database? → Serverless (or edge with HTTP-compatible client, but usually not worth it)
  • Does it need to run before the page loads and affects routing? → Edge (middleware territory)
  • Does it use Node.js-specific packages or native modules? → Serverless
  • Is it pure logic on request data — headers, cookies, URL — with no external calls? → Edge
  • Does it need to process webhooks from third-party services? → Serverless
  • Does it need to run globally with sub-10ms overhead? → Edge
  • Are you unsure? → Serverless. You can always move it to the edge later.
When in doubt, use serverless. The edge runtime's constraints will reveal themselves quickly in development, but it's easier to move code from serverless to edge than to debug mysterious runtime errors because you picked edge when you didn't need to.

Next.js Specifics Worth Knowing

In Next.js, you can set the runtime per route. The default is Node.js (serverless). To opt into the edge runtime, you export a runtime config:

// app/api/flags/route.ts — explicitly opt into edge
export const runtime = 'edge'

export async function GET(req: Request) {
  const country = req.headers.get('x-vercel-ip-country') ?? 'unknown'
  
  // Simple feature flag logic — no DB, pure logic
  const flags = {
    newDashboard: ['RO', 'DE', 'FR'].includes(country),
    betaCheckout: false,
  }
  
  return Response.json(flags)
}

// app/api/users/route.ts — stays on Node.js (default)
// No runtime export needed — Node.js is the default
export async function GET() {
  const users = await db.query.users.findMany()
  return Response.json(users)
}

Middleware in Next.js (the `middleware.ts` file) always runs at the edge — you don't get to choose. This is a feature, not a bug. It means your middleware has to be written with edge constraints in mind. Keep it lightweight: check cookies, read headers, redirect or rewrite. Don't try to do database lookups in middleware unless you have an HTTP-compatible client and understand the latency implications.

One more thing: Vercel's edge network has a 4MB compressed bundle limit for edge functions. This has caught us off guard when we tried to use a large dependency that pulled in half of Node.js. Next.js will warn you during build if you're close to the limit, but it's worth knowing upfront.

The Practical Summary

Most applications don't need to think about this much. You'll have a handful of middleware checks at the edge and everything else in regular serverless functions. The 'run everything at the edge for maximum performance' narrative is mostly marketing — the bottleneck in your app is almost never the regional latency of a serverless function, it's your database queries.

Where we've landed: edge for Next.js middleware (auth guards, redirects, A/B tests), serverless for everything else. On the templates we build at peal.dev, this pattern covers 95% of what real applications need — and we've stopped overthinking the remaining 5% unless profiling actually shows a problem.

Start with serverless. Move specific routes to the edge when you have a concrete reason — not because it sounds cooler or because someone on Twitter said edge-first is the future. Your future self at 2am debugging a 'module not found' error in production will thank you.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders