# From 4.5s to 90ms: Solving Webhook Timeouts with Vercel Workflow

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:

1. **Log the entry:** Write a `received` status to the database.
    
2. **Process the logic:** Run complex business logic (provisioning licenses, sending emails).
    
3. **Update the log:** Write a `processed` status 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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1765686576482/b1343c5f-450f-4c84-90a4-b6766518fd91.png align="center")

## 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.

```typescript
// 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.

```typescript
// 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.

```typescript
// 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.

```typescript
// 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.

```typescript
// 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:

```typescript
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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1765686917236/e2af7de6-6ae4-4d14-adb8-d5a77aa59190.png align="center")

| **Metric** | **Before (Synchronous)** | **After (Workflow)** |
| --- | --- | --- |
| **Response Time** | ~4500ms | **~90ms** |
| **Timeout Buffer** | &lt; 10% | **\&gt; 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

* [Vercel Workflow DevKit Docs](https://useworkflow.dev/docs/getting-started/next)
    
* [Paddle Webhook Guidelines](https://developer.paddle.com/webhooks/overview)
    

---

**Have you struggled with webhook timeouts in Next.js? Let me know how you solved it in the comments below!**
