From 4.5s to 90ms: Solving Webhook Timeouts with Vercel Workflow
How we moved from synchronous processing to durable workflows in Next.js to prevent data loss and sleep better at night.

When building integrations with third-party services like Paddle or Stripe, webhook handlers face a critical, often overlooked challenge: Response Time Constraints.
Most webhook providers enforce strict timeout limits (typically 5–10 seconds). If you don't respond with a 200 OK within that window, the provider assumes you failed. They will retry the webhook, potentially leading to duplicate processing, or worse—give up entirely.
The 4500ms Nightmare
Recently, our Paddle webhook integration started behaving dangerously. We were experiencing response times of approximately ~4500ms.
With Paddle’s timeout limit set to 5000ms, we were living on the edge.
Why was it so slow?
Our architecture was synchronous. When a webhook hit our server, we tried to do everything at once:
Log the entry: Write a
receivedstatus to the database.Process the logic: Run complex business logic (provisioning licenses, sending emails).
Update the log: Write a
processedstatus to the database.
The Risks
While this worked in local dev, in production it created three critical issues:
Timeout Risk: A slight network blip or database latency would push us over 5000ms, triggering retries.
Data Integrity: If the handler failed on step 3, the provider would retry, and we might provision the license twice (Step 2).
Scalability: Synchronous processing locks up server resources, making us vulnerable to burst traffic.
The Solution: Asynchronous Durable Workflows
We needed to decouple the acknowledgment of the event from the processing of the event.
We implemented the Vercel Workflow Development Kit to transform our synchronous handler into an asynchronous, durable workflow.
The Result? We reduced our response time from ~4500ms to ~90ms — a 98% improvement.
Here is how we architected it using Next.js.
The Architecture Change
Instead of doing the work during the request, we simply validate the request, queue the work, and respond immediately.

Implementation Guide
Here is the step-by-step implementation to move from synchronous code to Vercel Workflow.
1. Configure Next.js
First, wrap your Next.js config to enable workflow directives.
// next.config.ts
import { withWorkflow } from 'workflow/next';
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// ... your Next.js configuration
};
export default withWorkflow(nextConfig);
2. Update Middleware
The SDK creates internal endpoints at /.well-known/workflow/*. You must exclude these from your middleware authentication/redirect logic so the workflow engine can communicate with your app.
// middleware.ts
export const config = {
matcher: [
// Exclude .well-known/workflow/ from middleware
'/((?!_next/static|_next/image|favicon.ico|.well-known/workflow/).*)',
],
};
3. The New Route Handler (The 90ms Fix)
This is the most important change. Notice how we do not await the business logic. We only await the start() command, which simply queues the job.
// app/webhooks/paddle/[webhookId]/route.ts
import { start } from 'workflow/api';
import { workflowPaddleWebhook } from '@/workflows/webhooks/paddle';
export const POST = async (
request: Request,
{ params }: { params: Promise<{ webhookId: string }> }
): Promise<Response> => {
try {
const { webhookId } = await params;
// 1. Verify signature (Security is still synchronous!)
const isValid = await verifyWebhookSignature(request, webhookId);
if (!isValid) return new Response('Invalid signature', { status: 401 });
const eventData = await request.json();
// 2. Start workflow asynchronously - returns immediately!
await start(workflowPaddleWebhook, [
organizationId,
'production',
eventData,
]);
// 3. Respond immediately
return new Response('Webhook processed', { status: 200 });
} catch (error) {
console.error('Error starting webhook workflow', error);
return new Response('Internal server error', { status: 500 });
}
};
4. Defining the Workflow
The workflow file acts as the orchestrator. The 'use workflow' directive enables automatic retries and state persistence.
// workflows/webhooks/paddle/index.ts
import {
stepCreateWebhookLog,
stepHandlePaddleWebhook,
stepUpdateWebhookLog,
} from './steps';
export const workflowPaddleWebhook = async (
organizationId: string,
environment: 'sandbox' | 'production',
eventData: PaddleWebhookPayload
) => {
'use workflow'; // <--- The magic directive
// Step 1: Log reception
const webhookLog = await stepCreateWebhookLog(organizationId, eventData);
// Step 2: Heavy lifting
await stepHandlePaddleWebhook(organizationId, environment, eventData);
// Step 3: Log completion
await stepUpdateWebhookLog(webhookLog.id);
return { success: true };
};
5. Defining the Steps
Each step is an isolated unit of work. If Step 2 fails (e.g., the database is temporarily down), the engine will retry only Step 2. Step 1 remains completed.
// workflows/webhooks/paddle/steps.ts
export const stepCreateWebhookLog = async (orgId: string, data: any) => {
'use step';
return await database.webhookLog.create({ /* ... */ });
};
export const stepHandlePaddleWebhook = async (orgId: string, env: string, data: any) => {
'use step';
// Dispatch based on event type
switch (data.event_type) {
case 'product.created':
await handleProductCreated(data.data, orgId);
break;
case 'subscription.created':
await handleSubscriptionCreated(data.data, orgId);
break;
}
};
The "Gotchas": Handling Idempotency
When you switch to an async retry system, idempotency is mandatory.
Because the workflow engine might retry a failed step, your code must be able to run twice without breaking things (e.g., charging a customer twice).
Always check if the work has already been done before processing:
export const handleSubscriptionCreated = async (data: any, orgId: string) => {
// Check if we already handled this Event ID
const existingEvent = await database.webhookEvent.findUnique({
where: { eventId: data.id },
});
if (existingEvent) {
console.log('Event already processed, skipping.');
return;
}
// If not, proceed...
await database.subscription.create({ /* ... */ });
};
Performance Comparison
The impact on our system stability was immediate.

| Metric | Before (Synchronous) | After (Workflow) |
| Response Time | ~4500ms | ~90ms |
| Timeout Buffer | < 10% | \> 99% |
| Retry Strategy | None (Fail = Data Loss) | Automatic & Durable |
| Scalability | Low (Blocking) | High (Queued) |
Conclusion
Migrating to Vercel Workflow DevKit didn't just speed up our response times; it fundamentally changed how we handle reliability. We no longer fear the 5-second timeout limit, and we have granular visibility into exactly which step of a webhook failed.
If you are dealing with critical webhooks (Payments, CI/CD, Email), move your logic out of the route handler. Your future self will thank you.
Links & References
Have you struggled with webhook timeouts in Next.js? Let me know how you solved it in the comments below!

