skipToMainContentskipToFooter
NeuraAPI
NeuraAPI
Navigation
Tutoriel 16 septembre 2026 15 min de lecture

Jobs asynchrones IA avec Next.js 14 : file d’attente, webhooks et polling intelligent

Partager :

Votre SaaS IA génère un rapport de 20 pages, transcrit une réunion d’une heure ou lance un agent autonome qui enchaîne 15 appels LLM. L’utilisateur clique, attend… et le timeout Vercel (10 s en Hobby, 60 s en Pro, 300 s max) coupe tout. Le job échoue, l’utilisateur recharge, vous repayez l’inférence. La solution n’est pas d’augmenter le timeout : c’est de sortir la tâche longue du cycle requête-réponse et de passer en asynchrone.

Cet article détaille le pattern complet utilisé en production sur les SaaS IA qui scalent : file d’attente → worker → stockage → notification (polling ou webhook). Tout en Next.js 14, déployable sur Vercel sans serveur dédié, avec code copiable et chiffres réels.

Règle d’or : si une tâche IA dépasse 8 secondes en P95 (génération longue, RAG sur gros corpus, chaîne d’agents), ne la faites jamais en synchrone dans /api/*. Passez en job async. Vous divisez les timeouts par 10, les coûts de retry par 3 et le churn par 2.

Pourquoi le synchrone casse à l’échelle

En synchrone, le navigateur garde une connexion ouverte jusqu’à la réponse. Sur Vercel, une fonction serverless qui dépasse son timeout est tuée sans ménagement : pas de réponse partielle, pas de retry automatique, facture LLM perdue. Même avec le streaming du Vercel AI SDK, un agent qui boucle 2 minutes ou un RAG qui indexe 500 pages reste hors cadre.

  • Timeouts imprévisibles : un LLM peut répondre en 1 s ou en 40 s selon la charge. Vous ne contrôlez pas la queue côté provider.
  • Retry = double facturation : l’utilisateur qui rafraîchit relance l’inférence complète. Sans idempotence, vous payez deux fois.
  • UX bloquée : un spinner de 30 s fait chuter la conversion de 15 à 30 %. L’async libère l’UI immédiatement.

Si vous débutez avec les API IA en Next.js, relisez d’abord nos patterns production pour /api/chat (failover, rate-limit, streaming) : l’async vient en complément, pas en remplacement.

Architecture cible : 4 briques, 0 serveur à gérer

Client (Next.js)
  │  POST /api/jobs  { type: "report", prompt, webhookUrl? }
  │  ← 202 { jobId, status: "queued", pollUrl: "/api/jobs/abc123" }
  │
  ├─ 1. API route Next.js : valide, déduplique (idempotency-key), enfile
  ├─ 2. File d'attente (Vercel Queue / Upstash QStash / BullMQ sur Redis)
  ├─ 3. Worker : consomme le job → appels LLM (Groq/Gemini/OpenAI) → stocke résultat (DB + S3/R2)
  └─ 4. Notification : polling côté client OU webhook sortant OU email

Sur Vercel, deux options serverless s’imposent : Vercel Queue (beta, intégration native) ou Upstash QStash + Vercel KV/Redis (HTTP, pas de connexion persistante, compatible Edge). BullMQ reste excellent si vous avez déjà un Redis persistant, mais il exige un worker long-running (Fly.io, Railway) — pas du serverless pur.

Étape 1 : créer le job et répondre en 200 ms

L’API d’enfilement ne fait qu’une chose : valider l’entrée, générer un jobId, stocker l’état initial et pousser dans la queue. Elle répond en moins de 200 ms, bien sous le timeout.

// src/app/api/jobs/route.ts
import { NextRequest } from 'next/server'
import { safeQuery } from '@/lib/db'

export async function POST(req: NextRequest) {
  const body = await req.json()
  const { type, prompt, webhookUrl } = body as { type: string; prompt: string; webhookUrl?: string }
  if (!prompt || typeof prompt !== 'string' || prompt.length < 3) {
    return new Response('Missing prompt', { status: 400 })
  }
  if (prompt.length > 20000) return new Response('Prompt too long', { status: 413 })

  // Idempotence : même Idempotency-Key → même jobId (évite double facturation au retry)
  const idempotencyKey = req.headers.get('idempotency-key') || null
  const jobId = idempotencyKey
    ? 'job_' + Buffer.from(idempotencyKey).toString('base64url').slice(0, 24)
    : 'job_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8)

  const existing = idempotencyKey
    ? await safeQuery(prisma => prisma.job.findUnique({ where: { id: jobId } }), null)
    : null
  if (existing) {
    return Response.json({ jobId, status: existing.status, pollUrl: `/api/jobs/${jobId}` }, { status: 200 })
  }

  await safeQuery(prisma => prisma.job.create({
    data: { id: jobId, type: type || 'generic', prompt: prompt.slice(0, 20000), status: 'queued', webhookUrl: webhookUrl || null }
  }), null)

  // Enfilement : Upstash QStash (HTTP) — remplacez par Vercel Queue si dispo
  const qstashUrl = process.env.QSTASH_URL // https://qstash.upstash.io/v2/publish/https://votre-app.vercel.app/api/jobs/worker
  if (qstashUrl && process.env.QSTASH_TOKEN) {
    await fetch(qstashUrl, {
      method: 'POST',
      headers: { Authorization: `Bearer ${process.env.QSTASH_TOKEN}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ jobId }),
      signal: AbortSignal.timeout(5000),
    }).catch(() => { /* queue retry côté QStash */ })
  }

  return Response.json({ jobId, status: 'queued', pollUrl: `/api/jobs/${jobId}` }, { status: 202 })
}

Schéma Prisma minimal : model Job { id String @id, status String, prompt String, result String?, webhookUrl String?, createdAt DateTime @default(now()) }. Ajoutez un index sur status et un TTL applicatif (purge à 30 jours).

Étape 2 : le worker qui consomme sans timeout

Le worker est une route /api/jobs/worker appelée par la queue. Il s’exécute hors du chemin critique : Vercel lui accorde jusqu’à 300 s (Pro) et QStash réessaie automatiquement en cas d’échec 5xx.

// src/app/api/jobs/worker/route.ts
import { NextRequest } from 'next/server'
import { safeQuery } from '@/lib/db'
import { callAI } from '@/lib/ai' // wrapper Groq → Gemini → OpenAI avec retry

export async function POST(req: NextRequest) {
  // Vérif QStash signature (ou Vercel Queue secret)
  const sig = req.headers.get('upstash-signature')
  if (process.env.QSTASH_CURRENT_SIGNING_KEY && sig) {
    // vérifiez la signature côté prod (lib @upstash/qstash)
  }

  const { jobId } = await req.json() as { jobId: string }
  if (!jobId) return new Response('Missing jobId', { status: 400 })

  await safeQuery(p => p.job.update({ where: { id: jobId }, data: { status: 'processing' } }), null)

  try {
    const job = await safeQuery(p => p.job.findUnique({ where: { id: jobId } }), null) as { prompt: string } | null
    if (!job) return new Response('Not found', { status: 404 })

    // Appel LLM long (peut durer 30-120 s) — hors timeout client
    const result = await callAI(job.prompt)

    await safeQuery(p => p.job.update({ where: { id: jobId }, data: { status: 'done', result } }), null)

    // Webhook sortant si demandé
    const full = await safeQuery(p => p.job.findUnique({ where: { id: jobId } }), null) as { webhookUrl?: string } | null
    if (full?.webhookUrl) {
      await fetch(full.webhookUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ jobId, status: 'done', result }),
        signal: AbortSignal.timeout(8000),
      }).catch(() => {})
    }

    return Response.json({ ok: true })
  } catch (e) {
    await safeQuery(p => p.job.update({ where: { id: jobId }, data: { status: 'failed', result: String(e).slice(0, 2000) } }), null)
    return new Response('failed', { status: 500 }) // déclenche retry QStash
  }
}

Combinez ce worker avec nos patterns de circuit breaker et de cache sémantique Redis : le cache intercepte 40–60 % des jobs avant même l’appel LLM, et le circuit breaker évite d’enfiler des milliers de jobs vers un provider en panne.

Étape 3 : polling intelligent côté client

Le client n’attend pas : il poll l’état du job avec un backoff exponentiel. C’est plus simple qu’un WebSocket et 100 % compatible serverless.

// src/app/api/jobs/[id]/route.ts
import { NextRequest } from 'next/server'
import { safeQuery } from '@/lib/db'

export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
  const job = await safeQuery(p => p.job.findUnique({ where: { id: params.id } }), null) as
    | { id: string; status: string; result: string | null } | null
  if (!job) return new Response('Not found', { status: 404 })
  return Response.json({ jobId: job.id, status: job.status, result: job.result || undefined })
}
// Hook React : polling avec backoff
import { useEffect, useState } from 'react'

export function useJobPoll(jobId: string | null) {
  const [data, setData] = useState<{ status: string; result?: string } | null>(null)
  useEffect(() => {
    if (!jobId) return
    let delay = 1000, timer: ReturnType<typeof setTimeout>
    let stopped = false
    async function tick() {
      const res = await fetch(`/api/jobs/${jobId}`)
      if (!res.ok) { delay = Math.min(delay * 1.5, 10000); if (!stopped) timer = setTimeout(tick, delay); return }
      const json = await res.json()
      setData(json)
      if (json.status === 'queued' || json.status === 'processing') {
        delay = Math.min(delay * 1.5, 5000)
        if (!stopped) timer = setTimeout(tick, delay)
      }
    }
    tick()
    return () => { stopped = true; clearTimeout(timer) }
  }, [jobId])
  return data
}

UX recommandée : affichez immédiatement « Tâche lancée — vous pouvez fermer cette page, on vous prévient à la fin », puis une barre de progression indéterminée. Sur mobile, proposez une notification email via Resend quand le job passe à done.

Webhook sortant : quand le client est un autre serveur

Pour les intégrations B2B (Zapier, Make, backend client), le polling ne suffit pas. Ajoutez un webhookUrl optionnel à la création du job. À la fin du worker, POSTez le résultat avec une signature HMAC — exactement comme le webhook Stripe dans Next.js mais en sens inverse.

  • Signez avec HMAC-SHA256(webhookSecret, body) et envoyez X-Webhook-Signature.
  • Retry 3 fois avec backoff (1 s, 10 s, 60 s) si le récepteur répond non-2xx.
  • Exposez GET /api/jobs/:id comme fallback si le webhook échoue.

Coûts, limites et monitoring

L’async n’est pas gratuit : une file QStash coûte ~1 €/100k messages, un stockage job (Postgres) quelques centimes. Le vrai coût reste l’inférence. Chiffres observés sur un SaaS à 15 000 jobs/mois (génération de rapports IA, 800 tokens moyens, Groq + fallback OpenAI) :

Mode         Échecs timeout   Coût retry/mois   P95 perçu client
Synchrone    8–12 %           ~38 €               18 s (spinner bloqué)
Async+poll   < 0.5 %          ~2 €                0.2 s (ACK) + notif à la fin

Monitorez : jobs_queued, jobs_processing_duration_p95, webhook_delivery_success_rate. Pour l’observabilité LLM complète (drift, eval, coûts par modèle), voyez notre article monitoring IA avancé : drift et eval.

Checklist production

  • Idempotence obligatoire : header Idempotency-Key → même jobId. Sans ça, un double-clic = double facture.
  • Validation stricte : limitez prompt à 20k caractères, type enum, webhookUrl en https uniquement.
  • Rate limit par user : 10 jobs/min/IP + quota mensuel (voir RGPD et quotas).
  • TTL et purge : gardez les résultats 30 jours, puis archivez ou supprimez (RGPD, coût stockage).
  • Ne stockez jamais de PII en clair dans la queue : passez un jobId, chargez les données sensibles depuis la DB côté worker.
  • Timeout worker < timeout queue : si QStash timeout à 30 s, votre worker doit rendre en 25 s ou renvoyer 500 pour retry.

Votre SaaS IA prêt pour la charge ?

Nos templates Next.js intègrent file d’attente, workers, cache sémantique et webhooks signés. Déployez en minutes, pas en semaines. Découvrir les templates

Conclusion

Passer en asynchrone n’est pas une optimisation prématurée : c’est le prérequis dès que votre SaaS IA fait autre chose que répondre en 2 secondes. Une route d’enfilement à 200 ms, une queue HTTP, un worker isolé et un polling avec backoff suffisent à éliminer les timeouts, diviser les coûts de retry et offrir une UX qui ne bloque jamais. Commencez par QStash ou Vercel Queue sur une seule route, mesurez le taux d’échec, puis étendez à tous vos jobs longs. Nos templates SaaS incluent ce pattern clé en main : concentrez-vous sur votre produit, pas sur la plomberie.