écrits/tutorial/2026/05
Tutorial20 mai 2026·32 min

Construire une file de tâches distribuée avec Hatchet, Postgres et TypeScript

Apprenez à construire des jobs d'arrière-plan et des workflows durables de niveau production avec Hatchet — une file de tâches adossée à Postgres pour TypeScript. Ce tutoriel couvre les tâches, les workflows DAG multi-étapes, les retries, le rate limiting, le contrôle de concurrence, les schedules cron et les déclencheurs événementiels.

Toute application web sérieuse finit par dépasser le modèle synchrone requête/réponse. Envoyer des emails, traiter des images uploadées, appeler des APIs IA lentes, synchroniser des données entre services, exécuter des rapports planifiés — rien de tout cela n'a sa place dans un handler HTTP. Tout doit se dérouler en arrière-plan de façon fiable, survivre aux déploiements, réessayer en cas d'échec, et passer à l'échelle horizontalement sur plusieurs workers.

La stack classique pour cela en TypeScript a longtemps été BullMQ sur Redis avec un dashboard maison, ou des services managés comme Inngest et Trigger.dev. Hatchet prend un angle différent : un projet open source, auto-hébergeable, construit directement sur Postgres. Pas de Redis. Pas de Kafka. La même base de données à laquelle vous confiez déjà vos données applicatives orchestre aussi votre travail d'arrière-plan, avec un état durable, des étapes exactly-once, du rate limiting, du contrôle de concurrence et des cron schedules intégrés.

Dans ce tutoriel, vous allez construire un pipeline de traitement média et de notifications pour une application Next.js : lorsqu'un utilisateur upload une image, Hatchet orchestre un workflow multi-étapes qui valide le fichier, génère des vignettes, lance un captioning IA, stocke les résultats et envoie une notification. Vous découvrirez en chemin comment Hatchet modélise les tâches, les workflows, les retries, le rate limiting, les schedules cron et les déclencheurs événementiels — et comment déployer une flotte de workers en production.

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20+ installé
  • Docker Desktop en cours d'exécution localement (pour le moteur Hatchet et Postgres)
  • Une connaissance basique de TypeScript et async/await
  • Une familiarité avec Next.js App Router (route handlers, server actions)
  • Un éditeur de code tel que VS Code

Aucun compte payant Hatchet Cloud n'est requis pour ce tutoriel — tout tourne en local via Docker.

Ce que vous allez construire

Un pipeline de traitement média composé de :

  • Tâche validateUpload — vérifie la taille, le type MIME et les dimensions du fichier
  • Tâche generateThumbnails — produit des variantes petite, moyenne et grande
  • Tâche captionImage — appelle une API de captioning IA avec retries
  • Tâche persistMetadata — écrit les résultats dans votre schéma Postgres applicatif
  • Tâche notifyUser — envoie une notification push ou un email
  • Workflow media-pipeline — un DAG qui enchaîne les tâches avec des branches parallèles
  • Un workflow de nettoyage planifié qui tourne chaque nuit
  • Un route handler Next.js qui déclenche le pipeline à l'upload

À la fin, vous comprendrez en quoi Hatchet se distingue des systèmes purement files d'attente, quand choisir une tâche unitaire plutôt qu'un workflow complet, et comment opérer des workers en production.

Pourquoi Hatchet plutôt que BullMQ ou Inngest

Petit cadrage avant de coder :

  • BullMQ est une file basée sur Redis. Excellente pour les queues FIFO ou à priorité, mais vous construisez vous-même par-dessus les workflows durables, les retries avancés, le dashboard et l'observabilité.
  • Inngest / Trigger.dev sont des plateformes d'exécution durable « SaaS-first ». La DX est remarquable, mais elles introduisent un verrouillage fournisseur et une facturation qui croît avec l'usage.
  • Hatchet est open source, auto-hébergeable, et utilise Postgres comme source de vérité. Vous obtenez un état durable, des workflows DAG, du fan-out, du rate limiting et des concurrency keys avec une base de données supplémentaire — souvent celle que vous exploitez déjà.

Si votre stack inclut déjà Postgres et que vous souhaitez éviter d'ajouter à la fois Redis et une dépendance SaaS, Hatchet est un excellent choix par défaut.

Étape 1 : Lancer Hatchet en local avec Docker

Créez un nouveau dossier de projet et démarrez la stack Hatchet locale. Hatchet fournit un docker-compose.yml unique qui démarre Postgres, RabbitMQ (utilisé en interne comme canal de notification à faible latence), le moteur Hatchet et le dashboard.

mkdir hatchet-media-pipeline && cd hatchet-media-pipeline
curl -L https://hatchet.run/install/docker-compose.yml -o docker-compose.yml
docker compose up -d

Une fois la stack démarrée, le dashboard Hatchet est disponible sur http://localhost:8080. Connectez-vous avec les identifiants par défaut affichés dans le terminal, créez un tenant et générez un token API depuis la page Settings → API Tokens. Sauvegardez-le comme variable d'environnement :

echo "HATCHET_CLIENT_TOKEN=your_token_here" > .env
echo "HATCHET_CLIENT_TLS_STRATEGY=none" >> .env

Le flag tls_strategy=none est important en dev local — Hatchet utilise gRPC sur TLS en production, mais la stack locale fonctionne en clair.

Étape 2 : Installer le SDK TypeScript

Initialisez un projet TypeScript et ajoutez le SDK officiel :

npm init -y
npm install @hatchet-dev/typescript-sdk
npm install -D typescript tsx @types/node dotenv
npx tsc --init

Mettez à jour tsconfig.json pour qu'il fonctionne bien avec Node moderne et ESM :

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src/**/*"]
}

Créez le point d'entrée pour la configuration Hatchet partagée :

// src/hatchet.ts
import "dotenv/config";
import { Hatchet } from "@hatchet-dev/typescript-sdk";
 
export const hatchet = Hatchet.init();

Hatchet.init() lit votre token API et les paramètres de connexion depuis les variables d'environnement. Aucune configuration supplémentaire n'est nécessaire pour la stack locale.

Étape 3 : Définir votre première tâche

Les tâches sont la plus petite unité de travail dans Hatchet. Chaque tâche a des entrées et sorties typées, des retries automatiques et un état durable. Créez la tâche de validation d'upload :

// src/tasks/validate-upload.ts
import { hatchet } from "../hatchet";
 
type Input = {
  fileUrl: string;
  mimeType: string;
  sizeBytes: number;
};
 
type Output = {
  ok: boolean;
  width: number;
  height: number;
};
 
export const validateUpload = hatchet.task({
  name: "validateUpload",
  retries: 2,
  fn: async (input: Input): Promise<Output> => {
    if (input.sizeBytes > 25_000_000) {
      throw new Error("file too large: must be under 25 MB");
    }
    if (!["image/png", "image/jpeg", "image/webp"].includes(input.mimeType)) {
      throw new Error(`unsupported mime type: ${input.mimeType}`);
    }
    const dims = await probeImage(input.fileUrl);
    return { ok: true, width: dims.width, height: dims.height };
  },
});
 
async function probeImage(url: string) {
  return { width: 1920, height: 1080 };
}

Plusieurs points à remarquer :

  • Entrée et sortie typées. Le SDK est fully type-safe de bout en bout, donc quand d'autres tâches consomment cette sortie, elles bénéficient de l'auto-complétion.
  • retries: 2. Hatchet réessaiera jusqu'à deux fois sur erreur avant de marquer la tâche comme échouée.
  • Fonction async pure. Pas de plomberie de queue, pas de callbacks done(). Lancer une erreur fait échouer la tâche, retourner une valeur la réussit.

Étape 4 : Lancer un worker

Une définition de tâche seule ne fait rien tant qu'un worker ne la prend pas en charge. Les workers sont des processus longue durée qui s'abonnent à une ou plusieurs tâches et les exécutent au fur et à mesure qu'elles sont planifiées.

// src/worker.ts
import { hatchet } from "./hatchet";
import { validateUpload } from "./tasks/validate-upload";
 
async function main() {
  const worker = await hatchet.worker("media-worker", {
    workflows: [validateUpload],
    slots: 10,
  });
  await worker.start();
}
 
main().catch((err) => {
  console.error(err);
  process.exit(1);
});

La valeur slots: 10 indique au worker qu'il peut exécuter jusqu'à dix tâches en parallèle. En production, vous ajustez cette valeur au profil CPU et mémoire du travail effectué.

Lancez-le dans un terminal séparé :

npx tsx src/worker.ts

Le worker s'enregistre auprès du moteur Hatchet et devient visible dans le dashboard.

Étape 5 : Déclencher la tâche

Dans un troisième terminal, exécutez un petit script qui déclenche la tâche et attend son résultat :

// src/trigger.ts
import { validateUpload } from "./tasks/validate-upload";
 
const result = await validateUpload.run({
  fileUrl: "https://example.com/avatar.png",
  mimeType: "image/png",
  sizeBytes: 1_240_000,
});
 
console.log("validation result:", result);
npx tsx src/trigger.ts

Vous devriez voir les dimensions validées s'afficher dans la console. Ouvrez le dashboard Hatchet et vous trouverez le run, sa durée, ses tentatives, ses logs et son payload d'entrée/sortie typé — le tout stocké durablement dans Postgres.

Étape 6 : Composer des tâches en workflow

Un workflow chaîne les tâches en DAG, chaque étape recevant les sorties de ses parents. Voici le pipeline média complet :

// src/workflows/media-pipeline.ts
import { hatchet } from "../hatchet";
 
type PipelineInput = {
  userId: string;
  fileUrl: string;
  mimeType: string;
  sizeBytes: number;
};
 
export const mediaPipeline = hatchet.workflow<PipelineInput>({
  name: "mediaPipeline",
  on: { event: "media:uploaded" },
});
 
const validate = mediaPipeline.task({
  name: "validate",
  retries: 2,
  fn: async (input, ctx) => {
    return ctx.runTask("validateUpload", {
      fileUrl: input.fileUrl,
      mimeType: input.mimeType,
      sizeBytes: input.sizeBytes,
    });
  },
});
 
const thumbs = mediaPipeline.task({
  name: "generateThumbnails",
  parents: [validate],
  retries: 3,
  fn: async (input, ctx) => {
    const dims = ctx.parents.validate;
    return generateThumbnails(input.fileUrl, dims);
  },
});
 
const caption = mediaPipeline.task({
  name: "captionImage",
  parents: [validate],
  retries: 5,
  rateLimits: [{ key: "ai-caption", units: 1, dynamic: true }],
  fn: async (input) => {
    return aiCaption(input.fileUrl);
  },
});
 
const persist = mediaPipeline.task({
  name: "persistMetadata",
  parents: [thumbs, caption],
  fn: async (input, ctx) => {
    return saveMetadata({
      userId: input.userId,
      thumbnails: ctx.parents.generateThumbnails,
      caption: ctx.parents.captionImage,
    });
  },
});
 
mediaPipeline.task({
  name: "notifyUser",
  parents: [persist],
  fn: async (input) => sendPushNotification(input.userId, "Your upload is ready"),
});

Ce que cela vous apporte :

  • Branches parallèles. generateThumbnails et captionImage dépendent tous deux de validate et s'exécutent en concurrence dès que la validation réussit.
  • Fan-in implicite. persistMetadata ne démarre qu'après la complétion des deux parents, leurs sorties typées étant disponibles sur ctx.parents.
  • Politiques de retry par tâche. L'appel IA, fragile, retente jusqu'à cinq fois, la génération de vignettes jusqu'à trois.
  • Rate limiting. L'appel IA participe à un bucket de rate-limit partagé nommé ai-caption, de sorte que toute la flotte de workers respecte un même plafond de débit.

Ajoutez le workflow à l'enregistrement du worker dans src/worker.ts :

const worker = await hatchet.worker("media-worker", {
  workflows: [validateUpload, mediaPipeline],
  slots: 10,
});

Étape 7 : Déclencher des workflows depuis Next.js

Câblez le pipeline dans un route handler Next.js App Router. Placez le fichier sous app/api/uploads/route.ts :

import { NextResponse } from "next/server";
import { mediaPipeline } from "@/src/workflows/media-pipeline";
 
export async function POST(req: Request) {
  const body = await req.json();
 
  const handle = await mediaPipeline.run({
    userId: body.userId,
    fileUrl: body.fileUrl,
    mimeType: body.mimeType,
    sizeBytes: body.sizeBytes,
  });
 
  return NextResponse.json({ workflowRunId: handle.workflowRunId });
}

mediaPipeline.run() met en file un run de workflow, persiste durablement l'input dans Postgres et retourne immédiatement avec un ID de run. Votre handler HTTP reste rapide, et le reste du travail continue en arrière-plan — même si Next.js redéploie en plein vol.

Pour des workflows déclenchés par des systèmes externes (webhooks Stripe, événements S3, événements Resend), émettez plutôt un événement Hatchet :

await hatchet.events.push("media:uploaded", {
  userId: body.userId,
  fileUrl: body.fileUrl,
  mimeType: body.mimeType,
  sizeBytes: body.sizeBytes,
});

Comme mediaPipeline est déclaré avec on: { event: "media:uploaded" }, chaque push de cet événement fan-out automatiquement en un run de workflow.

Étape 8 : Ajouter des contrôles de concurrence

Supposons que vous vouliez au plus trois runs concurrents par utilisateur, afin qu'un compte qui upload cent fichiers ne puisse pas affamer la queue des autres. Hatchet exprime cela de manière déclarative avec une concurrency key :

export const mediaPipeline = hatchet.workflow<PipelineInput>({
  name: "mediaPipeline",
  on: { event: "media:uploaded" },
  concurrency: {
    expression: "input.userId",
    maxRuns: 3,
    limitStrategy: "GROUP_ROUND_ROBIN",
  },
});

Le moteur regroupe les runs par input.userId, plafonne chaque groupe à trois workflows actifs et distribue les runs supplémentaires en round-robin. Aucun verrou maison. Aucun Redis. L'état vit dans Postgres.

Étape 9 : Planifier un nettoyage cron

Les jobs d'arrière-plan ne sont pas tous déclenchés par des requêtes. Ajoutez un nettoyage nocturne qui supprime les vignettes orphelines de plus de sept jours :

// src/workflows/cleanup.ts
import { hatchet } from "../hatchet";
 
export const cleanupOrphans = hatchet.workflow({
  name: "cleanupOrphans",
  on: { cron: "0 3 * * *" },
});
 
cleanupOrphans.task({
  name: "deleteOrphans",
  fn: async () => {
    const removed = await deleteOrphanThumbnails({ olderThanDays: 7 });
    return { removed };
  },
});

Enregistrez le workflow sur le worker et Hatchet gère la planification cron pour vous, y compris la coordination entre plusieurs répliques de workers — exactement un tir de cron par intervalle, sans doublons.

Étape 10 : Tester les tâches en isolation

Les tâches Hatchet sont de simples fonctions async, ce qui rend les tests unitaires triviaux. Extrayez la logique métier dans un module pur et testez-la directement :

// src/lib/validate.ts
export function validateMediaInput(input: {
  mimeType: string;
  sizeBytes: number;
}) {
  if (input.sizeBytes > 25_000_000) throw new Error("file too large");
  const allowed = ["image/png", "image/jpeg", "image/webp"];
  if (!allowed.includes(input.mimeType)) throw new Error("bad mime");
  return true;
}
// src/lib/validate.test.ts
import { describe, it, expect } from "vitest";
import { validateMediaInput } from "./validate";
 
describe("validateMediaInput", () => {
  it("accepts a valid PNG under 25 MB", () => {
    expect(validateMediaInput({ mimeType: "image/png", sizeBytes: 1_000 })).toBe(true);
  });
  it("rejects oversized files", () => {
    expect(() => validateMediaInput({ mimeType: "image/png", sizeBytes: 26_000_000 })).toThrow();
  });
});

Le wrapper de tâche se contente d'appeler validateMediaInput(). Vos tests n'ont jamais besoin de mocker Hatchet lui-même.

Étape 11 : Observer les runs en production

Le dashboard sur http://localhost:8080 est la même UI qu'en production. Pour chaque run de workflow, vous voyez :

  • Une timeline de chaque tâche, incluant temps d'attente en queue et temps d'exécution
  • Le payload complet d'entrée/sortie typé (chiffré au repos)
  • Les logs structurés émis par ctx.log()
  • Les tentatives de retry et leurs messages d'erreur
  • L'état des rate limits et des concurrency keys

Pour un monitoring programmatique, exposez les métriques Hatchet à Prometheus et alimentez un dashboard Grafana aux côtés de vos métriques applicatives existantes. Les percentiles de latence par tâche sont particulièrement utiles pour traquer les étapes lentes dans un DAG.

Étape 12 : Déployer une flotte de workers

Un déploiement en production consiste généralement en trois pièces :

  1. Le moteur Hatchet — exécuté via chart Helm, Fly Machines, Railway, ou votre cluster Kubernetes existant, pointant vers un Postgres managé (Neon, Supabase, RDS).
  2. Un pool de processus workers — votre code TypeScript, déployé comme conteneur longue durée (Fly Machines, Render, Railway, ECS) avec auto-scaling basé sur la profondeur de queue.
  3. Votre application — Next.js sur Vercel, un serveur Bun sur Coolify, un monolithe Laravel, ou autre. Elle a juste besoin du SDK pour enfiler des runs et pousser des événements.

Dockerisez le worker avec une image Node minimale :

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY dist ./dist
ENV NODE_ENV=production
CMD ["node", "dist/worker.js"]

Buildez une fois, déployez autant de répliques que votre débit l'exige. Hatchet gère la distribution du travail, vous vous concentrez sur la logique métier.

Dépannage

Quelques problèmes que vous pourriez rencontrer :

  • UNAVAILABLE: connection refused au démarrage d'un worker en local signifie généralement que la stack Docker n'est pas encore prête. Attendez que docker compose logs hatchet-engine affiche gRPC server listening.
  • Tâches bloquées en PENDING indiquent typiquement qu'aucun worker n'est enregistré pour ce workflow. Vérifiez que le workflow figure bien dans le tableau workflows: passé à hatchet.worker().
  • Les retries ne se déclenchent jamais si la fonction retourne proprement malgré un échec logique. Lancez une erreur pour signaler l'échec — c'est ainsi que Hatchet décide de réessayer.
  • Les clés de rate-limit ne s'appliquent pas à toute la flotte — assurez-vous que chaque worker enregistre la même définition de rate-limit. Des définitions divergentes créent des buckets indépendants.

Étapes suivantes

Vous disposez désormais d'une file de tâches durable, type-safe et adossée à Postgres qui anime un véritable pipeline média. Pour étendre le projet :

Conclusion

Hatchet fusionne la file, le framework de workers, le scheduler et le store d'état durable en un seul système adossé à Postgres. Vous écrivez de simples fonctions TypeScript, vous les composez en DAGs, vous déclarez les retries et le rate limiting comme des données, et vous laissez le moteur gérer le reste. Le modèle mental est compact, l'empreinte opérationnelle se limite à une base de données supplémentaire et le code source est ouvert — ce qui en fait un choix par défaut séduisant pour les équipes qui font déjà confiance à Postgres et veulent que le travail d'arrière-plan ressemble au reste de leur codebase.

Dans ce tutoriel, vous avez installé Hatchet sur Docker, défini des tâches typées, composé un workflow avec branches parallèles, appliqué retries et rate limits, planifié un nettoyage cron et déclenché tout le pipeline depuis une route Next.js. Les mêmes patterns passent à l'échelle pour gérer des milliers de jobs par seconde sur une flotte de workers, avec le même dashboard et le même Postgres comme unique source de vérité.