You deploy an LLM-calling, image-processing, or long API-aggregation endpoint to Vercel Edge / Cloudflare Workers / Netlify Edge. Locally it takes 5 seconds — fine. First few requests in prod work. Then traffic ramps and you start seeing FUNCTION_INVOCATION_TIMEOUT or 504s. Move it off edge and it works. You hit the edge runtime’s hard limits.
Mental model: Edge isn’t “faster Lambda” — it’s a low-latency distribution runtime with very tight CPU and wall-clock limits. Vercel Edge 25-30s, Cloudflare Workers free 10ms CPU / paid 30s wall, Netlify Edge ~50ms CPU. Heavy work on edge will time out.
Common causes
Ordered by hit rate, highest first.
1. Synchronous heavy work on edge (long LLM, image processing)
LLM calls routinely 20-60 seconds. PDF parsing, image generation take tens of seconds. Edge’s 30-second ceiling isn’t enough.
How to spot it: File has export const runtime = 'edge' and does work that takes > 10s.
2. Slow upstream with no timeout
await fetch(upstream) without a timeout — when upstream stalls, you stall. Some third-party APIs are usually 1s but occasionally 60s.
How to spot it: Logs show upstream latency several × baseline.
3. CPU limit tighter than wall time
Cloudflare Workers meter CPU time (real compute), not wall time. Free plan gives 10ms CPU — heavy JSON parse or crypto blows it.
How to spot it: Cloudflare dashboard → Workers → CPU time exceeds 10ms.
4. Serial upstream calls that should be parallel
const a = await fetch(api1); // 5s
const b = await fetch(api2); // 5s
const c = await fetch(api3); // 5s
// 15s total
Should be Promise.all.
How to spot it: Code has back-to-back awaits.
5. Buffering response instead of streaming
You assemble the full body before returning — slower than chunked streaming and can exceed buffer caps.
How to spot it: Client waits forever, then everything arrives at once (no progressive display).
6. Cold start eats several seconds
Edge first-invocation cold start can spend 2-5s on init, leaving only ~25s for your business logic.
How to spot it: First request slow, subsequent fast.
Shortest path to fix
Step 1: Confirm it’s an edge limit
// Vercel: this line at the top = edge
export const runtime = 'edge';
Remove it (or set 'nodejs') and redeploy. If it works, confirmed edge limits. Then decide whether to keep it on edge.
Step 2: Don’t put it on edge if you don’t have to
Rule of thumb:
What Where
==================================================
< 5s, pure routing / auth edge (low-latency edge is the point)
LLM calls, PDF processing nodejs serverless (30s-15min)
> 1 min background job (Inngest / Cron / Queue)
Persistent connections durable object / dedicated server
Move the LLM endpoint to regular serverless:
// Vercel Pages Router
export const config = { runtime: 'nodejs', maxDuration: 60 };
// Vercel App Router
export const runtime = 'nodejs';
export const maxDuration = 60; // Hobby 10s, Pro 60s, Enterprise 900s
Step 3: If you must stay on edge, stream
LLM responses → SSE / streaming so first byte arrives fast:
export const runtime = 'edge';
export async function POST(req) {
const upstream = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: { /* ... */ },
body: JSON.stringify({ ..., stream: true }),
});
return new Response(upstream.body, {
headers: { 'Content-Type': 'text/event-stream' },
});
}
Streaming sidesteps the total-duration limit (first byte is what matters) and plays nicely with edge.
Step 4: AbortSignal.timeout on every upstream fetch
const res = await fetch(upstream, {
signal: AbortSignal.timeout(20_000), // 20s
});
Don’t let slow upstreams drag you down. Combine with retry:
async function fetchWithTimeout(url, options, ms = 20_000) {
return fetch(url, {
...options,
signal: AbortSignal.timeout(ms),
});
}
Step 5: Parallelize when you can
// slow
const a = await fetch(api1);
const b = await fetch(api2);
// fast
const [a, b] = await Promise.all([fetch(api1), fetch(api2)]);
3 × 5s upstreams → 5s parallel vs 15s serial.
Step 6: For > 30s, switch to background jobs
// Request side: enqueue, return jobId immediately
export async function POST(req) {
const jobId = await enqueue({ task: 'generate-report', userId: ... });
return Response.json({ jobId });
}
// Client polls
async function poll(jobId) {
while (true) {
const { status, result } = await fetch(`/api/jobs/${jobId}`).then(r => r.json());
if (status === 'done') return result;
if (status === 'failed') throw new Error('job failed');
await sleep(2000);
}
}
Background runners:
- Inngest — functional background jobs, retries built in
- Trigger.dev — similar
- Vercel Cron + Queue
- Cloudflare Queues
Prevention
- Memorize your platform’s edge limits (Vercel 25-30s, Cloudflare 10ms-50ms-30s by plan, Netlify ~50ms)
- Anything that takes > 10s defaults off edge — start nodejs, only move to edge if you have a reason
- Every external fetch gets a timeout (5-20s) so one slow upstream can’t sink everything
- Catch back-to-back awaits with an eslint rule and migrate to Promise.all
- LLM / streaming endpoints → SSE; never wait for full response
- Monitor p95 / p99 latency, not avg — edge timeouts hit at the tail
- Document which endpoints are edge / nodejs / background; new code follows the pattern
Related
Tags: #Backend #Debug #Troubleshooting