Edge Functions vs. API Routes: How to Choose and When to Combine Both

The short version: Edge functions and serverless API routes solve different problems. Edge is for low-latency request manipulation — auth checks, redirects, A/B testing — and runs in 50ms or less, close to the user. API routes handle database writes, heavy computation, and business logic. Using them wrong costs you either latency or reliability.
Most teams pick one and overuse it. Either they put everything at the edge and hit database connection walls, or they route everything through a regional API and wonder why their auth checks feel slow. The right answer is a split architecture — and understanding which work belongs where.
What edge functions actually are
Edge functions run on a global CDN network — Cloudflare's ~300 PoPs, Vercel's Edge Network, Netlify's edge infrastructure. When a user in Singapore makes a request, an edge function handles it from a nearby PoP in under 5ms, rather than routing to a server in us-east-1 that adds 200ms of network latency.
The tradeoff: edge functions run in constrained V8 isolates. No persistent TCP connections. Strict CPU time limits (30–50ms depending on platform). No access to the Node.js standard library's filesystem or child_process APIs. A small subset of Web APIs only.
// Cloudflare Worker — runs at the edge, executes in <5ms
export default {
async fetch(request: Request): Promise<Response> {
const country = request.cf?.country;
// Redirect UK users to the UK-specific subdomain
if (country === 'GB') {
return Response.redirect('https://uk.example.com' + new URL(request.url).pathname, 301);
}
// Check auth token without a database call
const token = request.headers.get('Authorization')?.split(' ')[1];
if (!token || !isValidJWT(token)) {
return new Response('Unauthorized', { status: 401 });
}
// Pass to origin for actual processing
return fetch(request);
}
};
No database. No file system. Fast.
What serverless API routes actually are
API routes — AWS Lambda, Vercel Functions (non-edge), Netlify Functions — run in full Node.js (or Python, Go, Ruby) containers in a fixed region. They have access to the full runtime, can hold database connections (briefly), and can run for up to 15 minutes on Lambda.
The tradeoff: they're regional. A Lambda in us-east-1 adds 200–250ms round-trip latency for users in Europe or Asia-Pacific. And they have the cold start problem — if a function hasn't been invoked recently, the first request pays a 100–500ms penalty while the container initializes.
// Next.js API route — runs in a regional Node.js container
// app/api/orders/route.ts
import { db } from '@/lib/database';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const body = await request.json();
// Database write — this is what edge functions cannot do
const order = await db.order.create({
data: {
userId: body.userId,
items: body.items,
total: body.total,
},
});
// Trigger background job
await queue.dispatch('send-order-confirmation', { orderId: order.id });
return NextResponse.json({ orderId: order.id }, { status: 201 });
}
Full database access. Full Node.js runtime. Regional latency.
The decision matrix
| Work type | Edge function | API route |
|---|---|---|
| Auth token validation (JWT, session cookie) | Yes — no DB needed for stateless tokens | Only if token validation requires a DB lookup |
| Geolocation-based redirects | Yes | No — too slow |
| A/B testing / feature flags | Yes — serve different responses by cohort | Only if flag config requires a DB call |
| Rate limiting (approximate) | Yes — using Cloudflare's KV or Durable Objects | For exact rate limiting requiring persistent state |
| Database reads | No — use edge-compatible DB (Neon, PlanetScale) or pass to API | Yes |
| Database writes | No | Yes |
| Payment processing | No — too sensitive for edge, use regional for auditability | Yes |
| Image / PDF generation | No | Yes |
| WebSocket connections | Cloudflare Durable Objects only | Yes (with persistent servers) |
| Sending email | No | Yes |
| Heavy computation (>50ms CPU) | No | Yes |
The split architecture pattern
The pattern that works in production: edge handles auth and routing, API handles data.
User Request
↓
Edge Function (Cloudflare Worker / Vercel Edge)
- Validate JWT (stateless, no DB)
- Check geolocation, apply redirects
- Rewrite headers, add request ID
- Block known malicious patterns
↓ (passes enriched request)
Regional API Route (Lambda / Vercel Function)
- Connect to database
- Execute business logic
- Write records
- Dispatch background jobs
↓
Response cached at edge for subsequent requests
In Next.js, this is implemented via the middleware.ts file for edge logic and app/api/ routes for API logic:
// middleware.ts — runs at edge on every request
import { NextRequest, NextResponse } from 'next/server';
import { verifyJWT } from '@/lib/auth';
export const config = {
matcher: ['/api/:path*', '/dashboard/:path*'],
};
export async function middleware(request: NextRequest) {
const token = request.cookies.get('session')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
// JWT verification is CPU-fast — no DB call needed
const payload = await verifyJWT(token);
if (!payload) {
return NextResponse.redirect(new URL('/login', request.url));
}
// Attach user ID to request headers for the API route
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-id', payload.sub);
return NextResponse.next({ request: { headers: requestHeaders } });
}
The edge middleware validates the session token without hitting the database — JWT verification is pure computation. The user ID is forwarded in a request header so the API route doesn't have to repeat the auth check.
Database connections in serverless: the connection pool problem
This is the most common failure mode in serverless architectures. Each Lambda invocation opens a new database connection. Under concurrent load, 500 simultaneous Lambda invocations means 500 simultaneous connections. PostgreSQL's default max_connections is 100. The database falls over.
The fix is a connection pooler:
# PgBouncer config — runs as a sidecar or separate instance
# /etc/pgbouncer/pgbouncer.ini
[databases]
mydb = host=rds.endpoint.amazonaws.com port=5432 dbname=mydb
[pgbouncer]
listen_port = 6432
listen_addr = 0.0.0.0
auth_type = md5
pool_mode = transaction # transaction pooling — best for serverless
max_client_conn = 1000 # Lambda connections to PgBouncer
default_pool_size = 20 # PgBouncer connections to RDS
With transaction-mode pooling, PgBouncer holds 20 connections to the database but serves up to 1,000 Lambda clients. Each Lambda invocation borrows a connection for the duration of a transaction, then returns it immediately.
AWS RDS Proxy provides the same capability as a managed service — worth it if you're already on RDS and don't want to operate PgBouncer yourself.
Edge-compatible databases
If you need data access at the edge, you need a database that speaks HTTP, not TCP:
| Database | Protocol | Edge compatible | Notes |
|---|---|---|---|
| Neon (serverless Postgres) | HTTP + WebSocket | Yes | Postgres-compatible, HTTP API for edge |
| PlanetScale | HTTP | Yes | MySQL-compatible, branching model |
| Cloudflare D1 | SQLite via Workers API | Workers only | Tight Cloudflare integration |
| Cloudflare KV | HTTP | Yes | Key-value only, eventually consistent |
| Upstash Redis | HTTP | Yes | Redis-compatible, per-request billing |
| Traditional Postgres (RDS) | TCP | No | Use in regional API routes only |
For most applications, the right answer is: use traditional Postgres in your API routes (with a connection pooler), and use Upstash Redis or Cloudflare KV at the edge for session lookups, feature flags, and rate limiting state.
Real-world example: auth + data in a Next.js app
// 1. Edge middleware validates session token (no DB)
// middleware.ts
export async function middleware(request: NextRequest) {
const sessionToken = request.cookies.get('session')?.value;
const payload = sessionToken ? await verifyJWT(sessionToken) : null;
if (!payload && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
// 2. API route reads user data from Postgres (regional, with connection pool)
// app/api/user/profile/route.ts
import { db } from '@/lib/db'; // Prisma + PgBouncer
export async function GET(request: NextRequest) {
const userId = request.headers.get('x-user-id'); // set by edge middleware
const user = await db.user.findUnique({
where: { id: userId },
select: { name: true, email: true, plan: true },
});
return NextResponse.json(user);
}
// 3. Static pages cached at edge — no function invocation needed
// app/blog/[slug]/page.tsx
export const revalidate = 3600; // Cache for 1 hour at CDN edge
The auth check runs at the edge in <5ms. The database query runs regionally in ~20ms. Static pages are served directly from CDN cache with zero function invocations.
For teams building Laravel backends that connect to similar serverless frontend architectures, the Laravel developer guide covers the API design patterns that pair well with edge-authenticated frontends.
Frequently Asked Questions

Written by
FNA Team
CEO & Founder at FNA Technology
Specializing in AI, automation, and scalable software solutions — helping businesses leverage cutting-edge technology to drive growth and innovation.
Work with us