Async AI: Long-Running Agents and Event-Driven Workflows
# Not all AI tasks need to complete in one HTTP request. Learn async agent patterns: job queues, webhook callbacks, Temporal workflows, and polling APIs for AI tasks that take minutes or hours.
A video generation agent, a deep research task, a codebase analysis — these take minutes, not milliseconds. Trying to fit them into a single HTTP request causes timeouts, retries, and bad UX. Async AI patterns decouple execution from waiting, letting long-running agents complete reliably regardless of network conditions or client timeouts.
Why Synchronous AI Fails for Long Tasks
Client → HTTP request → Agent starts (10 seconds)...
...still running (30 seconds)...
← Connection timeout (60s limit)
Client gets: 504 Gateway Timeout
Agent keeps running (user doesn't know)
Agent finishes → result lost
HTTP requests have timeouts (30–120s for most configurations). LLM-heavy workflows routinely exceed this. The solution: async execution with a job ID the client can use to check progress.
Pattern 1: Job Queue + Polling
The simplest async pattern. User submits a job, gets an ID, polls for completion.
API Design
// POST /ai/jobs → returns job ID immediately
// GET /ai/jobs/:id → returns job status + result when complete
interface Job {
id: string;
status: "queued" | "processing" | "completed" | "failed";
type: string;
input: unknown;
result?: unknown;
error?: string;
createdAt: number;
startedAt?: number;
completedAt?: number;
progress?: { step: number; total: number; message: string };
}
API Routes (Next.js / Express)
// app/api/ai/jobs/route.ts
export async function POST(req: Request) {
const { type, input } = await req.json();
const job: Job = {
id: crypto.randomUUID(),
status: "queued",
type,
input,
createdAt: Date.now(),
};
await db.saveJob(job);
await jobQueue.enqueue({ jobId: job.id });
return Response.json({ jobId: job.id }, { status: 202 });
}
// app/api/ai/jobs/[id]/route.ts
export async function GET(req: Request, { params }: { params: { id: string } }) {
const job = await db.getJob(params.id);
if (!job) return new Response("Not Found", { status: 404 });
return Response.json(job);
}
Worker Process
// worker.ts — runs separately from the API server
import Queue from "bull";
const queue = new Queue("ai-jobs", { redis: process.env.REDIS_URL });
queue.process(async (job) => {
const { jobId } = job.data;
const jobRecord = await db.getJob(jobId);
await db.updateJob(jobId, { status: "processing", startedAt: Date.now() });
try {
const result = await runAITask(jobRecord.type, jobRecord.input, {
onProgress: async (step, total, message) => {
await db.updateJob(jobId, {
progress: { step, total, message },
});
},
});
await db.updateJob(jobId, {
status: "completed",
result,
completedAt: Date.now(),
});
} catch (err: any) {
await db.updateJob(jobId, {
status: "failed",
error: err.message,
completedAt: Date.now(),
});
}
});
Client-Side Polling
async function pollForResult(jobId: string, maxWaitMs = 300_000): Promise<Job> {
const startTime = Date.now();
let interval = 1000; // start polling every 1s
while (Date.now() - startTime < maxWaitMs) {
const response = await fetch(`/api/ai/jobs/${jobId}`);
const job: Job = await response.json();
if (job.status === "completed") return job;
if (job.status === "failed") throw new Error(job.error);
// Show progress
if (job.progress) {
console.log(`${job.progress.step}/${job.progress.total}: ${job.progress.message}`);
}
// Exponential backoff: 1s → 2s → 4s → max 10s
await sleep(interval);
interval = Math.min(interval * 1.5, 10_000);
}
throw new Error("Job timed out after waiting");
}
Pattern 2: Webhooks (Push Instead of Poll)
For server-to-server integrations, push the result instead of making the client poll:
interface WebhookConfig {
url: string;
secret: string; // for HMAC signature verification
events: string[]; // "job.completed", "job.failed", "job.progress"
}
async function deliverWebhook(config: WebhookConfig, event: string, payload: unknown) {
const body = JSON.stringify({ event, payload, timestamp: Date.now() });
const signature = createHmac("sha256", config.secret).update(body).digest("hex");
const response = await fetch(config.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Webhook-Event": event,
"X-Webhook-Signature": `sha256=${signature}`,
},
body,
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
// Retry webhook delivery with backoff
await scheduleRetry(config, event, payload);
}
}
// Receiving end: verify signature
function verifyWebhookSignature(body: string, signature: string, secret: string): boolean {
const expected = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
Pattern 3: Temporal for Durable Workflows
For complex multi-step AI workflows, Temporal provides durable execution — if the worker crashes mid-task, Temporal replays from the last checkpoint.
// workflow.go — runs on a Temporal worker
package workflows
import (
"time"
"go.temporal.io/sdk/workflow"
"go.temporal.io/sdk/activity"
)
type ResearchWorkflowInput struct {
Topic string `json:"topic"`
Depth int `json:"depth"`
UserID string `json:"userId"`
}
func ResearchWorkflow(ctx workflow.Context, input ResearchWorkflowInput) (string, error) {
ao := workflow.ActivityOptions{
StartToCloseTimeout: 10 * time.Minute,
RetryPolicy: &temporal.RetryPolicy{
MaximumAttempts: 3,
},
}
ctx = workflow.WithActivityOptions(ctx, ao)
// Step 1: Plan research
var plan ResearchPlan
if err := workflow.ExecuteActivity(ctx, PlanResearchActivity, input.Topic).Get(ctx, &plan); err != nil {
return "", err
}
// Step 2: Execute sub-tasks in parallel
var sources []string
futures := make([]workflow.Future, len(plan.SubTopics))
for i, subtopic := range plan.SubTopics {
futures[i] = workflow.ExecuteActivity(ctx, SearchAndSummarizeActivity, subtopic)
}
for _, future := range futures {
var result string
if err := future.Get(ctx, &result); err == nil {
sources = append(sources, result)
}
}
// Step 3: Synthesize final report
var report string
if err := workflow.ExecuteActivity(ctx, SynthesizeReportActivity, sources, input.Topic).Get(ctx, &report); err != nil {
return "", err
}
return report, nil
}
The QuantumSketch video generation pipeline at Shahriar Labs uses exactly this pattern — 4 activities (script, scenes, render, merge) in a Temporal workflow. If the Manim render crashes, Temporal replays from the render step automatically.
Pattern 4: SSE for Real-Time Progress
For web apps, Server-Sent Events push progress updates without polling:
// API route: GET /api/ai/jobs/:id/stream
export async function GET(req: Request, { params }: { params: { id: string } }) {
const jobId = params.id;
const stream = new ReadableStream({
async start(controller) {
const sendEvent = (event: string, data: unknown) => {
controller.enqueue(
new TextEncoder().encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
);
};
// Poll job status and push events
let attempts = 0;
while (attempts < 300) { // max 5 minutes
const job = await db.getJob(jobId);
if (!job) { sendEvent("error", { message: "Job not found" }); break; }
sendEvent("progress", {
status: job.status,
progress: job.progress,
});
if (job.status === "completed") {
sendEvent("completed", { result: job.result });
break;
}
if (job.status === "failed") {
sendEvent("failed", { error: job.error });
break;
}
await sleep(1000);
attempts++;
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
}
// Client:
const eventSource = new EventSource(`/api/ai/jobs/${jobId}/stream`);
eventSource.addEventListener("progress", (e) => updateProgressUI(JSON.parse(e.data)));
eventSource.addEventListener("completed", (e) => showResult(JSON.parse(e.data)));
eventSource.addEventListener("failed", (e) => showError(JSON.parse(e.data)));
Choosing the Right Pattern
| Scenario | Pattern | |---------|---------| | Task <30s, web client | Streaming response (no async needed) | | Task 30s–5min, web client | SSE progress + job API | | Task 5min+, web client | Job queue + polling | | Server-to-server integration | Webhooks | | Complex multi-step workflow | Temporal | | Any pattern that needs durability | Temporal |
FAQ
How do I handle job expiry? Set a TTL on job records (7 days is standard). After TTL, delete the job and its result. Notify users before deletion for large outputs.
What if the client disconnects mid-stream and misses progress?
Use the polling fallback: clients that disconnect reconnect and poll /api/ai/jobs/:id. The job record always reflects current state.
How do I prevent duplicate job submissions?
Accept a client-provided idempotency key. Store idemp_key → job_id in Redis with a 24-hour TTL. If the same key arrives again, return the existing job ID.
Is Temporal worth the operational overhead for smaller projects? For simple 2-step workflows: no, use Bull/BullMQ. For 5+ step workflows with dependencies, retries, and timeouts: yes, Temporal's durability guarantees are worth it. The managed Temporal Cloud removes ops overhead.
Can I use this pattern for real-time chat? No — chat is inherently synchronous (user types, model responds). Use streaming responses for chat. Async patterns are for background processing where the user submits a task and checks back later.