DEV Community

Cover image for Async Job Queues in Next.js — Handling Long-Running Tasks Without Timeouts
Aon infotech
Aon infotech

Posted on

Async Job Queues in Next.js — Handling Long-Running Tasks Without Timeouts

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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',
  });
}
Enter fullscreen mode Exit fullscreen mode

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,
  });
}
Enter fullscreen mode Exit fullscreen mode

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(),
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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)