The Pitfalls of Handling Webhooks on Vercel and Serverless Edge Functions
The Pitfalls of Handling Webhooks on Vercel and Serverless Edge Functions Frontend and full-stack developers now routinely ship production-grade apps on Vercel, Next.js (App...

The Pitfalls of Handling Webhooks on Vercel and Serverless Edge Functions
Frontend and full-stack developers now routinely ship production-grade apps on Vercel, Next.js (App Router), and edge runtimes like Cloudflare Workers. But as those apps scale, a specific class of bug shows up again and again: webhooks that quietly stop arriving.
Webhooks are the backbone of event-driven architecture — payment events from Stripe, order updates from Shopify, user lifecycle events from Clerk, push events from GitHub. Modern apps lean on them for real-time state sync. The problem is that the way third-party providers deliver webhooks and the way serverless/edge functions execute code were built around two different assumptions, and when they collide, events get dropped silently.
This post walks through why that happens, what's actually changed on Vercel's platform in the last year (the picture is more nuanced than most write-ups suggest), and the architecture pattern — a webhook buffer — that fixes it.
The Core Conflict: Long-Lived Events vs. Ephemeral Runtimes
Webhook providers (Stripe, Shopify, GitHub, Twilio) expect a fast HTTP 2xx acknowledgment — typically somewhere between 5 and 20 seconds depending on the provider. Miss the window and the provider treats the delivery as failed, logs it, and retries on its own schedule.
Serverless/edge runtimes (Vercel Functions, Cloudflare Workers) are built around short-lived, stateless request-response cycles. Even where the execution ceiling has gotten much more generous recently (more on that below), the runtime still spins up, does work, and shuts down per invocation — it isn't a long-running process sitting there waiting for slow dependencies.
┌─────────────────┐ Direct HTTP Hook (Slow/Fragile) ┌──────────────────────┐
│ API Provider │ ──────────────────────────────────────────> │ Vercel / Next.js │
│ (Stripe/Shopify)│ <────────────────────────────────────────── │ Edge Route Handler │
└─────────────────┘ Provider times out first -> Drops/Retries └──────────────────────┘
│
[Cold Start / DB Lock]
When a provider webhook hits a Vercel route handler directly (/api/webhooks/stripe), that handler has to do synchronous work — signature verification, DB writes, outbound API calls — before it can respond. If any of that is slow, the provider's clock runs out long before your function is done, regardless of how much execution time Vercel is willing to give you.
1. Vercel's Timeout Limits Have Changed — But That's Not the Whole Story
Older articles (and a lot of still-circulating advice) describe Vercel Hobby functions timing out after 10 seconds and Pro after 60. That was accurate for the classic serverless execution model, but Vercel's current default compute model, Fluid Compute, changed the numbers significantly. As of mid-2026, with Fluid Compute enabled (the default for new projects):
| Plan | Default duration | Maximum | Extended max (beta) |
|---|---|---|---|
| Hobby | 300s (5 min) | 300s | — |
| Pro | 300s | 800s | 1,800s (30 min) |
| Enterprise | 300s | 800s | 1,800s (30 min) |
Edge runtime functions are a separate case: they must begin sending a response within 25 seconds, and can then continue streaming for up to 300 seconds. There's also a platform-wide proxied request timeout of 120 seconds on all plans, including Hobby — this governs how long Vercel's CDN will wait for your backend to start sending data on rewrites/proxied routes.
So the honest framing in 2026 is: Vercel itself is no longer your bottleneck for most webhook handlers. The bottleneck is almost always the provider's clock, not Vercel's. Stripe, Shopify, and GitHub will all give up long before Vercel would ever terminate your function — see the table in section 3. A generous maxDuration on Vercel buys you nothing if the caller drops the connection at second 5 or second 10.
Where Vercel limits still bite: cold starts (below), and bundle/memory limits (2 GB memory on Hobby, up to 4 GB on Pro/Enterprise) if your handler does heavy in-process work like PDF generation or large payload transforms.
2. Cold Starts & Connection Pool Exhaustion
Cold starts are still real, even with Fluid Compute reducing their frequency by reusing warm instances across requests. When a webhook arrives after a period of inactivity:
- Latency penalty: community-reported cold starts for Vercel functions doing real work (ORM initialization, DB handshake) commonly land in the 300ms–2,500ms range, with some reports of 2–3 seconds for heavier setups (e.g., Prisma cold-connecting to Postgres).
- Database handshakes: a standard TCP Postgres connection takes several network round trips to establish. Serverless-native drivers that connect over HTTP/WebSocket (like Neon's serverless driver) cut that down to roughly 3–4 round trips instead of ~8, which matters a lot for "first query after cold start" latency.
- Connection exhaustion: a burst of concurrent webhooks (a flash sale, a bulk sync) can each spin up separate function instances, and each one opening its own DB connection will exhaust your database's connection limit fast. This is the classic "too many connections" webhook-triggered outage.
The current best practice on Vercel is to use a connection pooler (PgBouncer-style, e.g. Supabase's Supavisor, or Neon's built-in pooler) or a serverless-native driver, and — if you're on Fluid Compute — to explicitly release idle pool connections before a function instance suspends, which Vercel's @vercel/functions package now has a helper for.
3. Provider Auto-Deactivation & Silent Failures
This is where a lot of older advice gets specific numbers wrong, and where the differences between providers actually matter for how you design retries. Here's what's currently true for each:
| Provider | Response window | Retry behavior | What happens after retries are exhausted |
|---|---|---|---|
| Stripe | No single published number; docs say to return 2xx "prior to any complex logic." In practice, community reports put the effective ceiling around ~20 seconds. | Automatic, exponential backoff, for up to 3 days in live mode (roughly 16 attempts); 3 attempts over a few hours in test mode. | Events are marked failed but remain visible in the Stripe Dashboard / Events API for 30 days and can be manually reprocessed. Endpoints with sustained failures can be auto-disabled. |
| Shopify | 1-second connection timeout, 5-second total response timeout — not configurable per-webhook. | As of Shopify's September 2024 policy update: 8 retries over a 4-hour window with exponential backoff. (Older posts citing "19 retries over 48 hours" describe a policy that no longer applies.) | After 8 consecutive failures, Shopify automatically deletes the webhook subscription — it must be manually re-registered. There's no dashboard replay for missed events. |
| GitHub | 10-second timeout. | GitHub does not automatically retry failed deliveries. This is a meaningful difference from Stripe/Shopify — a timeout or non-2xx just gets logged as a failed delivery. | You have to manually redeliver from the "Recent Deliveries" UI, hit the API, or build your own polling/redelivery script against the deliveries endpoint. |
The practical takeaway: treating "Stripe, Shopify, and GitHub" as one homogenous retry behavior (as older articles do) is wrong in a way that matters. Shopify's window is brutally short (5 hours of leeway, tops, before your integration silently goes dark). GitHub gives you no safety net at all unless you build one. Stripe is the most forgiving, but even 3 days runs out during a multi-day outage or a stuck deploy.
4. Burst Traffic and Out-of-Order Execution
Webhooks are state-sensitive. A subscription lifecycle might fire customer.created followed almost immediately by customer.subscription.updated. None of the major providers guarantee delivery order — Stripe says so explicitly in its docs — and in a serverless environment where each request can land on a different, independently-scaled compute instance, a fast "warm" invocation of event #2 can genuinely finish before a "cold" invocation of event #1. That's a race condition baked into the architecture, not a bug in your code, and it has to be handled with idempotency and defensive ordering logic (e.g., re-fetching current state from the provider's API rather than trusting event order).
Evaluating Native Workarounds
waitUntil() and Next.js after()
With Fluid Compute, waitUntil() is now a first-class part of Vercel's execution model rather than a hack — it extends the function's lifecycle so background work can continue after the response is sent. For Next.js 15.1+, Vercel recommends using the built-in after() function instead of importing waitUntil() directly.
import { NextResponse } from 'next/server';
import { after } from 'next/server';
export async function POST(req: Request) {
const payload = await req.json();
// Respond immediately
after(async () => {
await processHeavyWebhookTask(payload); // runs after the response is sent
});
return NextResponse.json({ received: true }, { status: 200 });
}
This is real and useful, but it doesn't solve the core problem:
- No retry or dead-letter queue. If
processHeavyWebhookTask()throws, the background task dies. You've already returned a 200, so the provider will never resend the event. - Still bounded by
maxDuration. Background work insideafter()/waitUntil()still has to finish within your function's configured maximum duration. - No concurrency control. Nothing here throttles how many webhooks hit your database or downstream APIs at once during a burst.
Vercel Workflows
Vercel has also introduced Workflows as a native option for durable execution — code that can pause, resume, and hold state for minutes to months without a duration ceiling. It's a genuinely different tool than waitUntil(): closer to a durable orchestration engine than a fire-and-forget background task. It's worth evaluating for the processing side of a webhook pipeline, but it isn't itself a webhook ingestion/dedup/replay layer — you still need something that instantly acknowledges the provider, persists the payload, and hands off to a workflow (or your handler) at a controlled pace.
Self-Hosting a Queue (SQS, Redis, BullMQ)
[Stripe] ──> [Next.js Route] ──> [Upstash Redis] ──> [Worker Function] ──> [Database]
This gets you real queueing, but it comes with real overhead: standing up and securing a message queue, writing your own retry/backoff and dead-letter logic, deduplication, and paying for continuous polling or trigger infrastructure alongside your existing Vercel bill. It's a legitimate path — plenty of teams run it — but it's weeks of engineering, not an afternoon.
The Webhook Buffer Pattern
Instead of pointing providers directly at your Vercel endpoint, a webhook buffer sits in front of it: it ingests and durably stores the raw event immediately, acknowledges the provider fast enough that it never has reason to retry, and then forwards the event to your application at a pace your infrastructure can actually handle.
┌────────────────┐ HTTP POST ┌──────────────────────┐ Fast ack ┌────────────────┐
│ API Provider │ ──────────────────> │ Webhook Buffer │ ───────────> │ API Provider │
│(Stripe/Shopify)│ │ (durable ingest layer) │ │ (Acknowledged)│
└────────────────┘ └──────────────────────┘ └────────────────┘
│
│ Controlled concurrency, retries, replay
▼
┌──────────────────────┐
│ Vercel / Next.js │
│ Serverless Endpoint │
└──────────────────────┘
InstaWebhook (instawebhook.com) is one implementation of this pattern. Based on its current docs and product pages, it works roughly like this:
- Durable endpoints: you create an endpoint, get an ingest URL with an embedded token, and point providers at it. Incoming payloads are accepted, encrypted, and stored before any delivery work happens — decoupling "we received this" from "we finished processing it."
- Delivery timeline: each event tracks through received → queued → attempted → retried → delivered / dead-lettered states, with timestamps, so a failed delivery is visible and debuggable instead of silently gone.
- Idempotency support: the ingest API accepts an
Idempotency-Keyheader so retried sends from your own systems (or accidental duplicates) don't get processed twice. - BYO database mode: for sensitive payloads, you can keep storage in your own infrastructure instead of a third-party's.
- Manual replay: failed or dead-lettered events can be inspected and replayed once your endpoint is healthy again.
This is the same category of tool as established players like Hookdeck and Svix — worth evaluating against each other on pricing, framework/language support, and self-host vs. managed if you're choosing one. The point of this pattern isn't the specific vendor; it's decoupling ingestion (which has to be instant and can't fail) from processing (which can be slow, retried, and rate-limited) — solved once, instead of re-solved per webhook route.
Code Comparison: Direct Handling vs. Buffered Pattern
Anti-pattern — everything happens inside the provider's response window:
// app/api/webhooks/stripe/route.ts
// ⚠️ VULNERABLE: cold starts, DB locks, or a slow email API push you past Stripe's clock
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
let event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err: any) {
return NextResponse.json({ error: `Webhook Error: ${err.message}` }, { status: 400 });
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object as any;
try {
await db.user.update({
where: { stripeCustomerId: session.customer },
data: { isSubscribed: true },
});
await fetch('https://api.emailprovider.com/send', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.EMAIL_API_KEY}` },
body: JSON.stringify({ to: session.customer_details?.email, template: 'welcome' }),
});
} catch (dbErr) {
// ⚠️ Returning an error here triggers a Stripe retry of the whole event
return NextResponse.json({ error: 'Database update failed' }, { status: 500 });
}
}
return NextResponse.json({ received: true }, { status: 200 });
}
Buffered pattern — your route only handles pre-validated, throttled, already-durable events:
// app/api/webhooks/inbound/route.ts
// ✅ RELIABLE: idempotent handler behind a durable buffer
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('x-webhook-signature')!;
// 1. Verify the buffer's own signature (check your provider's docs for the current helper)
const isValid = verifyBufferSignature(body, signature, process.env.WEBHOOK_BUFFER_SECRET!);
if (!isValid) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const { eventType, payload, eventId } = JSON.parse(body);
// 2. Idempotency check — safe against replays and duplicate deliveries
const existingEvent = await db.processedEvent.findUnique({ where: { id: eventId } });
if (existingEvent) {
return NextResponse.json({ message: 'Event already processed' }, { status: 200 });
}
// 3. Business logic, running at a pace your DB can absorb
if (eventType === 'checkout.session.completed') {
await db.user.update({
where: { stripeCustomerId: payload.customer },
data: { isSubscribed: true },
});
}
// 4. Record as processed
await db.processedEvent.create({ data: { id: eventId, processedAt: new Date() } });
return NextResponse.json({ success: true }, { status: 200 });
}
Best Practices for Serverless Webhook Handling
- Decouple ingestion from processing. Storing the event and acting on it should be two separate steps, whether that separation is a buffer service, a queue, or Vercel Workflows.
- Enforce idempotency. Track a unique event ID and skip anything you've already processed — this protects you from provider retries, buffer retries, and your own redeliveries alike.
- Use serverless-aware database access. A connection pooler (Supavisor, PgBouncer) or a serverless-native driver (Neon's HTTP/WebSocket driver, Prisma Accelerate) avoids connection-pool exhaustion during bursts.
- Know your provider's actual clock. Design around Shopify's 5-second/4-hour window and GitHub's lack of auto-retry, not around a generic "providers retry for a while" assumption.
- Monitor delivery health, not just your own logs. Stripe, Shopify, and GitHub all expose delivery status in their dashboards/APIs — check those, since a webhook that's failing silently on the provider side won't necessarily throw an error you'll notice in your own monitoring.
Conclusion
The mental model needs an update: Vercel's own execution limits, thanks to Fluid Compute, are no longer the main constraint for most webhook workloads in 2026 — you can now run functions for minutes, not seconds. The real constraint is still the provider's clock (5–20 seconds depending on who's calling you), and providers differ meaningfully in what happens after that clock runs out — Shopify deletes your subscription after 4 hours, GitHub doesn't retry at all, Stripe gives you 3 days.
Native tools like after()/waitUntil() and Vercel Workflows are real improvements over a couple of years ago, but they still don't give you retries, dead-lettering, or concurrency control for the ingestion problem. That's what a webhook buffer — self-hosted or a managed product like InstaWebhook, Hookdeck, or Svix — is for: acknowledge fast, store durably, forward at a rate your app can handle.
Further reading
- Vercel Functions limits: vercel.com/docs/functions/limitations
- Vercel proxied request / platform limits: vercel.com/docs/limits
- Stripe webhooks documentation: docs.stripe.com/webhooks
- Shopify webhook troubleshooting: shopify.dev/docs/apps/build/webhooks/troubleshoot
- GitHub webhook delivery troubleshooting: docs.github.com/en/webhooks
- Neon connection latency guide: neon.com/docs/connect/connection-latency