Most Next.js tutorials cover the happy path — a request comes in, the server responds quickly, done. But what happens when the task takes 15 seconds? Server functions time out. Users get errors. The experience breaks.
Here's the pattern that solves it, used in production for any long-running server-side task.
Why Standard API Routes Break on Long Tasks
Next.js API routes (and serverless functions generally) have execution time limits. On Vercel's free tier, that's 10 seconds. Even on paid plans, holding a connection open for 30+ seconds is unreliable — networks drop, browsers time out, proxies intervene.
The naive approach:
// ❌ This breaks on long tasks
export async function POST(request) {
const result = await doSomethingThatTakes20Seconds();
return Response.json({ result }); // Often never reaches here
}
The fix is to decouple job submission from job completion.
The Async Queue Pattern
Client submits job → API returns job ID immediately →
Client polls status endpoint →
Worker processes job in background →
Status endpoint returns result when ready
Three pieces: submit endpoint, status endpoint, and background worker.
Step 1 — Submit Endpoint (Returns Immediately)
// app/api/jobs/submit/route.js
import { createJob } from '@/lib/queue';
export async function POST(request) {
const { payload } = await request.json();
if (!payload) {
return Response.json(
{ error: 'Payload required' },
{ status: 400 }
);
}
// Create job — returns immediately with an ID
const jobId = await createJob({ payload });
return Response.json({
jobId,
status: 'queued',
});
}
The client gets a jobId back in milliseconds. The actual work hasn't started yet.
Step 2 — Status Endpoint (Polling Target)
// app/api/jobs/[jobId]/route.js
import { getJobStatus } from '@/lib/queue';
export async function GET(request, { params }) {
const { jobId } = params;
const job = await getJobStatus(jobId);
if (!job) {
return Response.json(
{ error: 'Job not found' },
{ status: 404 }
);
}
return Response.json({
jobId,
status: job.status, // 'queued' | 'processing' | 'completed' | 'failed'
result: job.result,
error: job.error,
createdAt: job.createdAt,
completedAt: job.completedAt,
});
}
Step 3 — Simple In-Memory Queue (Development)
// lib/queue.js
const jobs = new Map();
export async function createJob({ payload }) {
const jobId = crypto.randomUUID();
jobs.set(jobId, {
id: jobId,
payload,
status: 'queued',
result: null,
error: null,
createdAt: new Date().toISOString(),
completedAt: null,
});
// Process asynchronously — don't await
processJob(jobId).catch(console.error);
return jobId;
}
export async function getJobStatus(jobId) {
return jobs.get(jobId) ?? null;
}
async function processJob(jobId) {
const job = jobs.get(jobId);
if (!job) return;
jobs.set(jobId, { ...job, status: 'processing' });
try {
const result = await runTask(job.payload);
jobs.set(jobId, {
...job,
status: 'completed',
result,
completedAt: new Date().toISOString(),
});
} catch (error) {
jobs.set(jobId, {
...job,
status: 'failed',
error: error.message,
completedAt: new Date().toISOString(),
});
}
}
Important: In-memory storage resets on server restart and doesn't work across multiple instances. For production, replace Map with Redis or a database.
Step 4 — Frontend Polling
'use client';
import { useState, useCallback, useEffect, useRef } from 'react';
export function useJobPoller() {
const [status, setStatus] = useState('idle');
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
const pollerRef = useRef(null);
const stopPolling = useCallback(() => {
if (pollerRef.current) {
clearInterval(pollerRef.current);
pollerRef.current = null;
}
}, []);
const startJob = useCallback(async (payload) => {
setStatus('submitting');
setResult(null);
setError(null);
try {
const submitRes = await fetch('/api/jobs/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ payload }),
});
const { jobId } = await submitRes.json();
setStatus('queued');
pollerRef.current = setInterval(async () => {
const statusRes = await fetch(`/api/jobs/${jobId}`);
const data = await statusRes.json();
setStatus(data.status);
if (data.status === 'completed') {
setResult(data.result);
stopPolling();
}
if (data.status === 'failed') {
setError(data.error);
stopPolling();
}
}, 2000);
} catch (err) {
setStatus('failed');
setError(err.message);
}
}, [stopPolling]);
useEffect(() => () => stopPolling(), [stopPolling]);
return { startJob, status, result, error };
}
Production Considerations
| Concern | Solution |
|---|---|
| Job persistence | Replace Map with Redis or database |
| Multiple instances | External queue (BullMQ, SQS, etc.) |
| Job expiry | TTL on stored jobs |
| Polling overhead | WebSockets for high-frequency updates |
| Stuck jobs | Timeout detection + auto-fail |
When to Use This Pattern
Use async queues when:
- Task duration exceeds 5-10 seconds reliably
- You need progress feedback during processing
- Task failure should be recoverable without losing the request Skip it when:
- Task completes in under 3 seconds consistently
- Simplicity matters more than edge case handling
What I Built With This
I implemented this exact pattern in pixova.io — a free AI image generator. Every generation request goes through this async queue: the UI gets a job ID instantly, polls every 2 seconds, and renders the image when the GPU inference completes. The queue handles cold starts, retries, and timeout detection cleanly.
If you want to see the end-user experience this powers — open pixova.io, type a prompt, and watch the polling happen in real time in your network tab.
Questions about specific parts of the implementation? Comments open.
Top comments (0)