The moment your AI SaaS transitions from simple chat completions to multi-step agentic runs, document processing, or batch generations, the synchronous HTTP request-response model breaks. Web browsers and serverless platforms like Vercel enforce strict timeout limits—often as low as 10 to 15 seconds. If your LLM chain takes 45 seconds to synthesize a comprehensive report, your user gets a 504 Gateway Timeout, and you get a wasted API bill for a generation that never reached the client.
To scale, you must transition to **asynchronous workflows**—architectural patterns where a client triggers a task, receives an immediate confirmation receipt, and walks away while background workers process the heavy lifting. Once finished, the worker uses **webhook callbacks** (HTTP POST requests sent to a predefined endpoint) or database subscriptions to deliver the payload.
The Event-Driven Architecture
Instead of tying up a synchronous thread, decouple your pipeline:
```text [Client] --(1. Trigger Job)--> [Next.js Route] --(2. Enqueue Task)--> [Upstash Queue] ^ | | v (3. Trigger) [Supabase Realtime] <--(5. Write Result)-- [Background Worker] <------------+ ```
The Economics of Async Processing
Holding open a serverless HTTP connection for 60 seconds is a financial leak. On Vercel, you pay for execution duration; idling while waiting for upstream LLM tokens to generate drains your budget.
| Architecture | Concurrency Limit | Cost per 10k Runs (60s each) | Timeout Risk | | :--- | :--- | :--- | :--- | | Synchronous Serverless | Fast exhaustion of concurrent execution limits | ~$12.00 (Vercel compute duration) | Extremely High (10-15s limits) | | Asynchronous Queue + Worker | Infinite queueing; controlled worker concurrency | ~$0.40 (Upstash Redis + QStash) | Zero |
Implementing an Upstash Worker
Here is a production-ready route handler and background worker using Upstash Redis to queue, track, and process a multi-minute AI generation task.
```typescript // app/api/generate/route.ts import { Redis } from '@upstash/redis'; import { NextResponse } from 'next/server';
const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL!, token: process.env.UPSTASH_REDIS_REST_TOKEN!, });
export async function POST(req: Request) { const { prompt, userId } = await req.json(); const jobId = `job_${Math.random().toString(36).substring(2, 15)}`;
// Initialize job state in Redis await redis.hset(jobId, { status: 'queued', userId, prompt, createdAt: Date.now(), });
// Trigger the background worker asynchronously via QStash await fetch(`https://qstash.upstash.io/v1/publish/${process.env.WORKER_URL}`, { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.QSTASH_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ jobId }), });
return NextResponse.json({ jobId, status: 'queued' }); } ```
And the background worker endpoint that executes the long-running LLM generation:
```typescript // app/api/worker/route.ts import { Redis } from '@upstash/redis'; import { OpenAI } from 'openai'; import { NextResponse } from 'next/server';
const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL!, token: process.env.UPSTASH_REDIS_REST_TOKEN!, });
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export async function POST(req: Request) { // Verify QStash signature here in production to prevent unauthorized runs const { jobId } = await req.json();
const job = await redis.hgetall<{ status: string; prompt: string; userId: string }>(jobId); if (!job || job.status !== 'queued') { return new Response('Invalid job state', { status: 400 }); }
await redis.hset(jobId, { status: 'processing' });
try { const response = await openai.chat.completions.create({ model: 'gpt-4o', messages: [{ role: 'user', content: job.prompt }], temperature: 0.2, });
const output = response.choices[0].message.content;
// Update state and store the final payload await redis.hset(jobId, { status: 'completed', result: output, completedAt: Date.now(), });
// In production, push to Supabase or trigger a webhook callback here return NextResponse.json({ success: true }); } catch (error) { await redis.hset(jobId, { status: 'failed', error: String(error) }); return NextResponse.json({ success: false }, { status: 500 }); } } ```
By decoupling execution from the client request, you insulate your frontend from upstream API latency. The browser polls the `jobId` status or listens to a Supabase real-time channel. If OpenAI takes two minutes to return a complex output, your user sees a smooth progress bar instead of a broken connection page.
But this shift introduces a new danger. Once you move tasks to the background, you lose the immediate visibility of synchronous errors. If your background worker fails silently, users sit staring at spinning wheels indefinitely, racking up invisible API charges while your system retries broken requests in the dark.