Agentes IA y tareas largas: async jobs en Next.js
Tu agente procesa un PDF de 40 páginas, extrae los datos relevantes, hace tres llamadas al LLM para analizarlos y escribe un informe en base de datos. Tarda 2 minutos y 50 segundos. Tu función en Vercel tiene un límite de 60 segundos.
El usuario ve un spinner que gira. Después ve un error. Tú miras los logs, aumentas maxDuration a 300 y relanzas. La semana siguiente el PDF tiene 90 páginas. El timeout vuelve.
Este es el problema que el streaming no resuelve. Un streamText() funciona bien cuando el agente genera una respuesta larga de forma continua. No funciona cuando necesitas cinco llamadas al LLM en cadena, tiempos de espera entre herramientas y múltiples escrituras en base de datos.
Por qué los agentes reales rompen el modelo síncrono
El instinto natural al construir un agente es tratarlo como una API: el cliente envía una petición, el servidor procesa, el cliente recibe la respuesta. Funciona con operaciones que duran milisegundos.
Los agentes IA en workflows reales no duran milisegundos. Un agente que procesa facturas, genera borradores de contratos o coordina subagentes puede tardar entre 1 y 10 minutos por ejecución. Tres problemas aparecen inmediatamente:
- Timeouts de serverless: Vercel Edge Function tiene 25 segundos, las Serverless Functions de plan Pro tienen hasta 5 minutos en teoría pero con latencia real acumulada los timeouts aparecen antes.
- Conexiones HTTP inestables: mantener una conexión abierta 3 minutos desde un móvil con señal mediocre es una ruleta rusa.
- Reintentos sin estado: si el cliente no recibe respuesta y reintenta, el agente ejecuta el mismo trabajo dos veces, gasta el doble de tokens y puede producir datos duplicados.
La solución existe en el protocolo HTTP desde hace décadas y se usa en todas las APIs que procesan trabajo pesado: devolver 202 Accepted y procesar en background.
El patrón: POST → 202 → job queue → polling
El flujo tiene cuatro pasos, cada uno con una responsabilidad clara:
- POST /api/agent/process — el cliente envía la tarea. El API crea un registro en base de datos, devuelve 202 + un
jobIden menos de 200ms y termina. Sin procesar nada. - Worker sin límite de tiempo — un proceso separado, fuera del ciclo de vida de la petición HTTP, recoge el job y lo ejecuta. Puede tardar lo que necesite.
- GET /api/agent/status/:jobId — el cliente hace polling cada 2-3 segundos para comprobar el estado del job.
- Resultado disponible — cuando el status cambia a
done, el cliente lee el output. Si algo falló, lee el error.
Este patrón es idempotente: si el cliente reintenta el POST, puede recibir el mismo jobId para el mismo input y leer el resultado del primero sin que el agente se ejecute dos veces.
Implementación en TypeScript + Next.js + Supabase
La tabla de jobs en Supabase
create table agent_jobs (
id uuid default gen_random_uuid() primary key,
status text default 'pending', -- pending | processing | done | error
input jsonb not null,
output jsonb,
error text,
tenant_id uuid references auth.users(id),
created_at timestamptz default now(),
updated_at timestamptz default now()
);
alter table agent_jobs enable row level security;
create policy "own_jobs"
on agent_jobs for all
using (tenant_id = auth.uid());
El endpoint que crea el job
// app/api/agent/process/route.ts
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
export async function POST(req: Request) {
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) return new Response('Unauthorized', { status: 401 });
const input = await req.json();
// Crear el job — no procesar nada aquí
const { data: job, error } = await supabase
.from('agent_jobs')
.insert({ input, tenant_id: session.user.id, status: 'pending' })
.select('id')
.single();
if (error) return Response.json({ error: error.message }, { status: 500 });
// Disparar el worker (Supabase Edge Function, servidor propio, Inngest...)
await fetch(`${process.env.WORKER_URL}/run`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.WORKER_SECRET}`
},
body: JSON.stringify({ jobId: job.id })
});
// 202 Accepted — el trabajo está en curso, pregunta después
return Response.json({ jobId: job.id }, { status: 202 });
}
Este endpoint responde en menos de 200ms porque no ejecuta nada del agente. La llamada a fetch para disparar el worker es fire-and-forget: no esperamos su respuesta.
El endpoint de polling
// app/api/agent/status/[jobId]/route.ts
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
export async function GET(
_req: Request,
{ params }: { params: { jobId: string } }
) {
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) return new Response('Unauthorized', { status: 401 });
const { data: job } = await supabase
.from('agent_jobs')
.select('status, output, error, updated_at')
.eq('id', params.jobId)
.single();
if (!job) return new Response('Not found', { status: 404 });
return Response.json(job);
}
El hook de React que gestiona el polling
// hooks/useJobStatus.ts
import { useEffect, useState, useCallback } from 'react';
type JobStatus = 'pending' | 'processing' | 'done' | 'error';
interface JobState {
status: JobStatus;
output: unknown;
error: string | null;
}
export function useJobStatus(jobId: string | null): JobState {
const [state, setState] = useState<JobState>({
status: 'pending',
output: null,
error: null
});
const poll = useCallback(async () => {
if (!jobId) return false;
try {
const res = await fetch(`/api/agent/status/${jobId}`);
if (!res.ok) return false;
const job = await res.json();
setState({ status: job.status, output: job.output, error: job.error });
return job.status === 'done' || job.status === 'error';
} catch {
return false; // red inestable — el siguiente tick lo reintenta
}
}, [jobId]);
useEffect(() => {
if (!jobId) return;
let cancelled = false;
const run = async () => {
while (!cancelled) {
const finished = await poll();
if (finished || cancelled) break;
await new Promise(r => setTimeout(r, 2500));
}
};
run();
return () => { cancelled = true; };
}, [jobId, poll]);
return state;
}
La versión con while en lugar de setInterval evita solapamiento de peticiones si el servidor responde más lento de lo esperado.
El worker que procesa el agente sin límite de tiempo
El worker puede ser una Supabase Edge Function (tiempo de ejecución extendido en su plan Pro), un servidor Node separado o cualquier proceso que no esté limitado por el timeout de serverless.
// worker/process-job.ts
import Anthropic from '@anthropic-ai/sdk';
import { createClient } from '@supabase/supabase-js';
const anthropic = new Anthropic();
// Aquí usamos SERVICE KEY porque el worker corre en servidor propio, no en cliente
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY!
);
export async function processJob(jobId: string): Promise<void> {
await supabase
.from('agent_jobs')
.update({ status: 'processing', updated_at: new Date() })
.eq('id', jobId);
try {
const { data: job } = await supabase
.from('agent_jobs')
.select('input')
.eq('id', jobId)
.single();
if (!job) throw new Error('Job not found');
// Aquí ejecutas tu agente — puede tardar minutos, no hay timeout
const result = await runLongRunningAgent(job.input, anthropic);
await supabase
.from('agent_jobs')
.update({ status: 'done', output: result, updated_at: new Date() })
.eq('id', jobId);
} catch (err) {
await supabase
.from('agent_jobs')
.update({
status: 'error',
error: err instanceof Error ? err.message : 'Error desconocido',
updated_at: new Date()
})
.eq('id', jobId);
}
}
Cuándo usar async jobs y cuándo no
No todo agente necesita este patrón. El criterio es sencillo:
Usa async jobs cuando:
- El agente encadena más de 2-3 llamadas al LLM
- Hay pasos que dependen de APIs externas lentas (OCR, embeddings masivos, búsqueda semántica sobre corpus grande)
- El tiempo estimado supera los 30 segundos en condiciones reales de producción
- El trabajo puede fallar a mitad y necesitas estado para reintentar desde donde se quedó
Usa streaming en su lugar cuando:
- Es un chat donde el usuario espera la respuesta en tiempo real
- El agente hace una sola llamada al LLM y el output puede mostrarse progresivamente
- Ver el texto generarse en tiempo real mejora la experiencia (edición, redacción asistida)
Los dos patrones no son excluyentes. Un agente async puede emitir actualizaciones de progreso via SSE mientras procesa, combinando lo mejor de los dos mundos. El job mantiene el estado; el stream entrega feedback en tiempo real.
El coste de no implementarlo desde el principio
El patrón síncrono no solo produce timeouts: produce trabajo duplicado y datos inconsistentes. Cuando el cliente reintenta porque no recibió respuesta a tiempo, el agente vuelve a ejecutarse, gasta el doble de tokens, y puede escribir resultados parciales o duplicados en base de datos.
Añadir un job queue a un sistema ya desplegado tiene su coste: refactorizar endpoints, añadir la tabla de estado, actualizar el cliente para hacer polling, desplegar el worker. En un sistema con 8 agentes distintos ese refactor puede tomar días.
Montarlo desde el primer agente son horas. Y la diferencia en robustez es la que separa un sistema que un CTO aprobaría en revisión de uno que no.
Para agentes más complejos, este patrón se complementa bien con circuit breakers y estrategias de retry en las llamadas internas del worker. Y si el agente maneja datos de múltiples clientes, el patrón de JWT + RLS garantiza que cada job solo accede a los datos de su tenant.
Si estás construyendo un agente con workflows de larga duración o ya tienes timeouts en producción, hablamos antes de que llegue a más usuarios.