Guide du débutant sur les Durable Objects de Cloudflare
Cloudflare Durable Objects expliqué : routage, état à instance unique, stockage SQLite et exemple TypeScript de limiteur de débit avec Workers.
Un Durable Object est une instance unique d’une classe JavaScript que Cloudflare exécute à un seul endroit à la fois ; chaque requête qui désigne cette instance lui est acheminée, où que la requête ait été émise dans le monde, et l’instance dispose de son propre stockage privé.
Construisez un compteur, un verrou ou une liste « qui se trouve dans cette salle » sur de simples Workers, et deux requêtes peuvent finir par être en désaccord. Le Worker qui a traité la première requête et celui qui a traité la seconde peuvent être des isolats différents, dans des villes différentes, sans mémoire partagée entre eux.
Ce guide explique le modèle de routage qui fait fonctionner les Durable Objects, puis présente le plus petit exemple TypeScript qui l’illustre, en utilisant un limiteur de débit par clé d’API comme fil conducteur unique. Il suppose que vous connaissez déjà les bindings, wrangler et le handler fetch ; si vous avez d’abord besoin de ces bases, commencez par le guide du débutant OpenReplay sur Cloudflare Workers.
Points clés à retenir
- Les Durable Objects sont une primitive de calcul qui embarque son propre stockage privé, et non un produit de stockage que vous interrogez depuis un Worker ; ce stockage n’est accessible que depuis le code qui s’exécute à l’intérieur de l’objet.
- La chaîne de caractères passée à
env.BINDING.getByName(name)constitue l’identité de l’objet : chaque requête émise sur le réseau Cloudflare qui transmet la même chaîne atteint la même instance en cours d’exécution. - Chaque Durable Object possède une base de données SQLite embarquée sur le même thread que son code, si bien que
this.ctx.storage.sql.exec()renvoie un curseur de manière synchrone, sans nécessiter d’await. - Les champs de classe survivent entre deux requêtes consécutives, mais sont supprimés lorsque l’objet entre en hibernation après une dizaine de secondes d’inactivité ; tout ce qui doit persister doit être placé dans
ctx.storage. - Utilisez Workers KV lorsque de nombreux emplacements lisent les mêmes données et qu’une écriture peut mettre du temps à devenir visible partout ; utilisez un Durable Object lorsque plusieurs clients doivent s’accorder sur la valeur courante au même instant.
Pourquoi les Workers sans état échouent-ils à coordonner ?
Un Worker ne conserve rien entre les requêtes. Deux appels peuvent atterrir sur des isolats différents, à des endroits différents, et aucun ne peut voir ce que l’autre a fait ; toute fonctionnalité qui exige que des requêtes consécutives s’accordent sur une valeur est donc cassée. Les recommandations de conception pour les Durable Objects publiées par Cloudflare tracent exactement cette frontière entre les Workers sans état et la coordination avec état.
Voici le limiteur de débit naïf qui semble correct mais ne l’est pas :
// Broken: this Map exists per isolate. Another isolate has its own copy.
const hits = new Map<string, number>();
export default {
async fetch(request): Promise<Response> {
const key = request.headers.get("x-api-key") ?? "anonymous";
const count = (hits.get(key) ?? 0) + 1;
hits.set(key, count);
return new Response(count > 10 ? "slow down" : "ok", {
status: count > 10 ? 429 : 200,
});
},
} satisfies ExportedHandler;
La Map déclarée au niveau du module vit dans un seul isolat. Un client qui envoie 30 requêtes réparties sur trois isolats se retrouve avec trois compteurs indépendants qui s’arrêtent chacun à 10, et la limite n’est jamais appliquée. Déplacer le compteur dans une base de données externe résout le partage, mais introduit une situation de concurrence entre lecture et écriture pour les requêtes simultanées. Le problème n’est pas l’endroit où résident les données ; c’est que rien ne garantit un lieu unique où la vérification et la mise à jour ont lieu ensemble.
L’idée centrale : un objet par nom, toujours la même instance
Les Durable Objects résolvent le problème de coordination en attribuant à chaque nom exactement une instance en cours d’exécution et en y acheminant toutes les requêtes portant ce nom. La page de présentation des concepts expose les trois propriétés qui sous-tendent ce fonctionnement : chaque objet répond à un nom unique à l’échelle mondiale, son stockage se trouve auprès de lui plutôt qu’au bout d’un réseau, et il n’exécute qu’une seule chose à la fois, à la manière du JavaScript dans un onglet de navigateur.
Trois propriétés découlent de ce modèle :
- L’identité, c’est le nom. Votre Worker choisit une chaîne de caractères (une clé d’API, un identifiant de salle, un identifiant de document) et la plateforme la fait correspondre à une seule instance. Deux Workers situés sur des continents différents qui transmettent la même chaîne dialoguent avec le même objet.
- La création est implicite. Il n’existe pas d’appel de création. La référence de l’API namespace précise qu’un ID en lui-même ne crée rien, et que les objets ne sont pas construits tant que quelque chose ne les atteint pas réellement. En pratique, le constructeur s’exécute à l’arrivée du premier appel de méthode sur le stub.
- L’exécution est mono-thread. Le code synchrone au sein d’une méthode ne peut pas être interrompu par une autre requête. D’autres requêtes ne peuvent s’exécuter que pendant que votre code attend des E/S hors stockage, comme un
fetch().
Parce que chaque objet correspond à un thread sur une machine, le débit se met à l’échelle horizontalement plutôt que verticalement : un limiteur de débit doit se traduire par un objet par clé d’API, jamais par un objet global pour tout le trafic.
Comment définir et enregistrer une classe Durable Object ?
Un Durable Object est une classe exportée qui étend DurableObject depuis cloudflare:workers, reçoit ctx et env dans son constructeur et expose ses méthodes publiques aux Workers via RPC. Le guide de démarrage fixe la signature du constructeur à (ctx: DurableObjectState, env: Env) avec un appel obligatoire à super(ctx, env).
import { DurableObject } from "cloudflare:workers";
export class RateLimiter extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.ctx.storage.sql.exec(
"CREATE TABLE IF NOT EXISTS hits (bucket INTEGER PRIMARY KEY, count INTEGER NOT NULL)"
);
}
async increment(limit: number, windowMs: number): Promise<{ allowed: boolean; remaining: number }> {
const bucket = Math.floor(Date.now() / windowMs);
// No await between the read and the write: nothing else can run in between.
const row = this.ctx.storage.sql
.exec<{ count: number }>("SELECT count FROM hits WHERE bucket = ?", bucket)
.toArray()[0];
const count = row ? row.count + 1 : 1;
this.ctx.storage.sql.exec(
"INSERT INTO hits (bucket, count) VALUES (?, ?) ON CONFLICT(bucket) DO UPDATE SET count = ?",
bucket, count, count
);
return { allowed: count <= limit, remaining: Math.max(0, limit - count) };
}
}
Le SELECT et l’upsert s’exécutent sans aucun await entre eux : aucune autre requête portant sur cette clé d’API ne peut donc s’y glisser. C’est précisément ce que la version sans état ne pouvait pas offrir.
L’enregistrement de la classe nécessite deux entrées dans wrangler.jsonc. L’entrée exports déclare la classe comme un durable-object avec un stockage sqlite et c’est elle qui provisionne le namespace au premier déploiement. L’entrée durable_objects.bindings fournit au Worker une référence via env :
{
"durable_objects": {
"bindings": [
{ "name": "RATE_LIMITER", "class_name": "RateLimiter" }
]
},
"exports": {
"RateLimiter": {
"type": "durable-object",
"storage": "sqlite"
}
}
}
D’anciens exemples enregistrent les classes via un tableau migrations avec new_sqlite_classes. Cette forme reste prise en charge pour les Workers existants, mais exports est la méthode actuelle et les deux ne peuvent pas coexister dans un même fichier de configuration.
Comment appeler un Durable Object depuis un Worker ?
Un Worker atteint un Durable Object en demandant au binding un stub avec getByName(name), puis en appelant les méthodes publiques de la classe sur ce stub comme de simples fonctions asynchrones. Un stub n’est qu’une référence locale : les appels effectués dessus sont transmis à l’unique instance qui détient ce nom.
export default {
async fetch(request, env): Promise<Response> {
const key = request.headers.get("x-api-key") ?? "anonymous";
// `key` is the object's identity. Same key, same instance, everywhere.
const stub = env.RATE_LIMITER.getByName(key);
const { allowed, remaining } = await stub.increment(10, 60_000);
return new Response(allowed ? "ok" : "slow down", {
status: allowed ? 200 : 429,
headers: { "x-ratelimit-remaining": String(remaining) },
});
},
} satisfies ExportedHandler<Env>;
La chaîne passée à getByName() est la clé de routage. getByName(name) est un raccourci pour l’ancienne approche en deux étapes idFromName(name) suivi de get(id), que vous rencontrerez encore dans de nombreux exemples ; les deux désignent le même objet. Appeler directement les méthodes sur le stub en RPC exige une date de compatibilité égale ou postérieure à 2024-04-03, ce que satisfait n’importe quel nouveau template.
Où un Durable Object stocke-t-il son état ?
Chaque Durable Object dispose de deux types d’état : une base de données SQLite privée embarquée qui survit aux redémarrages, et de simples champs de classe qui n’existent que tant que l’objet réside en mémoire. L’API de stockage SQLite s’exécute sur le même thread que votre code, si bien que exec() renvoie immédiatement un SqlStorageCursor, sans await. Videz ce curseur avant le prochain await, à l’aide de .toArray(), .one() ou d’une boucle : un curseur laissé ouvert au travers d’un await peut récupérer des lignes écrites entre-temps, y compris des écritures qui feront ensuite l’objet d’un rollback.
Un champ de classe constitue le chemin rapide. En ajouter un au limiteur de débit met la différence en évidence :
export class RateLimiter extends DurableObject<Env> {
// In-memory: fast, private to this instance, gone after hibernation.
private lastSeen = 0;
async increment(limit: number, windowMs: number) {
this.lastSeen = Date.now();
// ... SQLite read and write as before (durable)
}
}
lastSeen survit entre deux requêtes consécutives, mais la documentation sur le cycle de vie indique qu’après 10 secondes sans événement entrant (et sans timer en attente, sans WebSocket standard-API ni fetch() en cours), l’objet entre en hibernation et sa mémoire est libérée. Les déploiements et la maintenance du runtime peuvent également le redémarrer à tout moment. La table hits, elle, survit à tout cela. Chaque objet adossé à SQLite peut contenir jusqu’à 10 Go sur l’offre Workers Paid, selon la page des limites.
Faut-il utiliser Workers KV ou les Durable Objects ?
Utilisez Workers KV lorsque de nombreux emplacements doivent lire les mêmes données et qu’il est acceptable qu’une écriture mette du temps à devenir visible partout ; utilisez un Durable Object lorsque plusieurs clients doivent s’accorder sur la valeur courante au même instant. La documentation sur la cohérence de KV est catégorique sur ce compromis : KV sacrifie la cohérence au profit de la vitesse, une modification peut mettre une minute ou plus à apparaître dans d’autres emplacements, et tout ce qui nécessite une lecture et une écriture atomiques conjointes doit plutôt passer par les Durable Objects.
| Workers KV | Durable Objects | |
|---|---|---|
| Cohérence | À terme ; les copies en cache expirent selon un TTL | Forte ; une seule instance détient les données |
| Lieu des lectures | N’importe quel emplacement, depuis le cache | À l’intérieur de l’unique instance propriétaire |
| Sûreté du couple lecture-écriture | Aucune entre les requêtes | Garantie en l’absence d’await hors stockage intercalé |
| Modèle d’écriture | Écritures peu fréquentes par clé | Écritures par objet sérialisées par le runtime |
| Usage typique | Configuration, feature flags, listes d’autorisation | Compteurs, verrous, salles, état par entité |
La comparaison des options de stockage classe les deux de la même façon : KV couvre la configuration et les valeurs similaires, bien plus souvent lues que modifiées, tandis que les Durable Objects couvrent la coordination entre clients et le stockage qui reste cohérent pour chaque objet. Un limiteur de débit est un compteur de type lecture-puis-écriture : sa place est donc dans un Durable Object. Un feature flag par tenant, lu à chaque requête, relève de KV.
Conclusion
Les Durable Objects résolvent la coordination en éliminant la question de l’emplacement de l’état : le nom que vous passez à getByName() sélectionne exactement une instance en cours d’exécution, sa base SQLite se trouve sur le même thread que son code, et une lecture suivie d’une écriture sans await hors stockage entre les deux ne peut pas être entrelacée. L’étape suivante consiste à générer le template Worker + Durable Objects avec npm create cloudflare@latest, à remplacer la classe générée par le limiteur de débit ci-dessus, puis à lancer npx wrangler dev pour voir le compteur grimper au fil de requêtes qui, autrement, ne s’accorderaient jamais.
FAQ
Quelle est la différence entre les Durable Objects et D1 ?
D1 est une base de données SQLite managée que votre Worker interroge sur le réseau, avec une API HTTP et des migrations de schéma intégrées. La base SQLite d'un Durable Object s'exécute sur la même machine que le code de l'objet et n'est accessible depuis les Workers qu'au travers de cet objet. Les deux plafonnent à 10 Go par base de données sur l'offre Workers Paid, et les deux ont des plafonds plus bas sur l'offre gratuite. Utilisez D1 pour une base relationnelle unique et partagée ; utilisez les Durable Objects pour un état par utilisateur ou par entité qui nécessite une coordination.
Combien de requêtes par seconde un seul Durable Object peut-il traiter ?
Un Durable Object unique est soumis à une limite souple d'environ 1 000 requêtes par seconde, car chaque objet s'exécute sur un seul thread d'une seule machine. Au-delà, le runtime met en file d'attente ce qu'il peut, puis fait échouer les appels excédentaires avec une erreur de surcharge. Chaque invocation dispose par défaut de 30 secondes de temps CPU, configurable jusqu'à 5 minutes avec limits.cpu_ms dans la configuration Wrangler. Mettez à l'échelle horizontalement avec un objet par nom, par exemple un objet par clé d'API.
Les Durable Objects fonctionnent-ils sur l'offre gratuite Workers ?
Oui. Les Durable Objects avec le backend de stockage SQLite sont disponibles sur l'offre gratuite Workers, dans la limite de 1 Go par objet, 5 Go de stockage Durable Objects au total par compte et 100 classes Durable Object. L'offre Workers Paid porte ces plafonds à 10 Go par objet, un stockage illimité par compte et 500 classes. Une fois un objet plein, les écritures échouent avec une erreur SQLITE_FULL, même si vous pouvez toujours lire des lignes et les supprimer pour libérer de l'espace.
Ai-je besoin de transactions explicites avec sql.exec() dans un Durable Object ?
En général, non. Chaque appel à sql.exec() s'exécute déjà au sein de sa propre transaction, et les lectures et écritures qui se succèdent sans await intercalé sont validées comme un unique lot atomique ; le couple lecture-écriture d'un limiteur de débit est donc sûr tel quel. sql.exec() ne peut pas exécuter d'instructions BEGIN TRANSACTION ni SAVEPOINT. Pour regrouper plusieurs instructions afin qu'elles soient toutes annulées si l'une d'elles lève une exception, utilisez ctx.storage.transactionSync(callback). Le callback doit être entièrement synchrone : non déclaré async et ne renvoyant aucune Promise.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k