Cloudflare Workers
CPU
Memória
Limites
Performance

Workers : limites de processeur et de mémoire – que la documentation n'explique pas bien

La limite de CPU de 10 ms du plan gratuit est largement mal comprise : le minuteur ne s'exécute que lorsque JavaScript est réellement en cours d'exécution, pas pendant les E/S.

Workers : limites de processeur et de mémoire – que la documentation n'explique pas bien

La limite de 10 ms du processeur sur le plan gratuit Workers effraie les nouveaux lecteurs. Dix millisecondes semblent ridiculement courtes pour quelque chose d'utile. La conséquence est que de nombreuses équipes passent immédiatement au forfait payant pour les 30 secondes de CPU — sans comprendre que le modèle de mesure est fondamentalement différent de celui d'un serveur traditionnel et que la plupart des travailleurs ont encore du CPU même dans les 10 ms gratuites. La limite de mémoire de 128 Mo est en revanche systématiquement sous-estimée et est responsable de toute une catégorie de pannes silencieuses en production.

Comment fonctionne réellement le minuteur du processeur

Le runtime Workers mesure le « temps CPU » – le temps pendant lequel le thread JavaScript exécute activement le code. Le temporisateur s'arrête lors de toute opération d'E/S asynchrone : await fetch(), await env.KV.get(), await db.query(), await env.BUCKET.get(). Pendant ces attentes, l'isolateur est inactif et le temporisateur n'avance pas.

L'effet pratique est grand. Un Worker qui effectue cinq appels séquentiels fetch() vers des API externes, chacun prenant 100 ms de latence réseau, a un temps d'horloge mural de 500 ms mais utilise peut-être 6 ms de CPU – juste la sérialisation des en-têtes, l'analyse de la réponse JSON et la logique métier entre les appels. Pour la plupart des Workers qui sont essentiellement des orchestrateurs d’E/S, la limite de 10 ms est généreuse.

Ce qui utilise réellement le CPU, ce sont des opérations intenses de manière synchrone : regex appliquées à de grandes chaînes, des objets avec une hiérarchie profonde ou de grands tableaux, des opérations cryptographiques même lorsque l'API est asynchrone (le travail de hachage se produit sur le CPU pendant l'exécution) et un encodage/décodage base64 sur de gros binaires. Un Worker qui reçoit une charge utile JSON de 500 Ko et effectue JSON.parse() dessus consacrera un temps CPU mesurable à cette opération : l'analyse est synchrone.

Sur le forfait payant, la limite va jusqu'à 30 secondes de temps CPU, ce qui est suffisant pour les cas d'utilisation intensifs en calcul : compression, génération d'images, inférence de petits modèles. Mais même sur des opérations payantes, les opérations du processeur qui durent plus de quelques secondes sont le symptôme d'une mauvaise conception pour la périphérie : les travailleurs ont été optimisés pour une faible latence et non pour un traitement lourd.

Le timer du processeur n'est pas ce qui tue les travailleurs en production

Ce qui fait réellement tomber les travailleurs dans la production sans avertissement clair, c'est la mémoire. La limite de 128 Mo semble raisonnable jusqu'à ce que vous compreniez ce qui compte : le script non compressé sur le tas V8, toutes les fermetures de modules qui existent dans la portée globale depuis le début de l'invocation, plus tout ce qui est alloué lors du traitement en cours de la requête.

Le script lui-même peut être plus consommateur que vous ne le pensez. Un Worker compressé de 500 Ko peut occuper 3 à 4 Mo après avoir été décompressé et analysé par V8. Les dépendances importées au niveau du module (bibliothèques de validation, analyseurs, SDK) restent en mémoire aussi longtemps que l'isolat est actif, même si elles ne sont pas utilisées dans la requête en cours. La portée globale est partagée entre les requêtes que les mêmes isolent de manière séquentielle dans le même PoP.

L'allocation qui déclenche le plus souvent la limite est response.arrayBuffer() ou request.arrayBuffer(). L’appel de cette méthode sur une réponse de 40 Mo alloue immédiatement 40 Mo de tas. Si le Worker crée ensuite plus de structures de données à partir de ce tampon (objets analysés, copies transformées), l'utilisation de la mémoire peut dépasser 128 Mo avant la fin du traitement. Le moteur d'exécution tue l'isolat à ce stade, le client reçoit une erreur 1101 et il n'y a aucune trace de pile - juste une erreur d'exécution signalée comme exception générique dans la queue de Wrangler.

Le streaming comme stratégie de survie de la mémoire

La solution au problème de mémoire avec des charges utiles volumineuses est de ne jamais matérialiser l’intégralité du contenu sous forme de tampon. Au lieu de response.arrayBuffer(), utilisez response.body — qui est un ReadableStream — et traitez les données en morceaux avec TransformStream.

L'API Workers streams suit la spécification WHATWG Streams, la même que celle disponible dans les navigateurs modernes. Un TransformStream a un writable et un readable : vous connectez le readable de la réponse amont au writable de la transformation, et vous connectez le readable de la transformation à la réponse que vous envoyez au client. Les morceaux circulent dans le pipeline sans jamais avoir d'entiers entiers en mémoire en même temps.

Cette architecture a une conséquence importante : on ne peut plus lire l'intégralité du contenu pour prendre des décisions qui dépendent de l'intégralité du dossier avant de commencer à répondre. Pour les cas où vous avez besoin d'un accès aléatoire (traitement d'un CSV nécessitant un ordre global, par exemple), Workers n'est pas l'endroit idéal. Pour les transformations qui fonctionnent morceau par morceau (recompression, remplacement de texte, filtrage de lignes), le pipeline de flux le résout sans pression sur la mémoire.

Pour les uploads allant vers R2, la liaison accepte directement un ReadableStream : await env.BUCKET.put(key, request.body, { httpMetadata }) — sans rien mettre en mémoire tampon. Le flux du corps de la requête va directement à R2 en morceaux.

Opérations surprenantes en raison de leur utilisation CPU

L’encodage et le décodage Base64 sont des opérations gourmandes en CPU proportionnelles à la taille des données – et le résultat occupe 33 % de mémoire en plus. Si vous transportez des fichiers binaires, il convient de se demander si un encodage est nécessaire ; De nombreuses utilisations de base64 sont des limitations HTTP héritées qui ne s'appliquent plus aux types binaires fetch et natifs.

Les opérations de hachage via l'API Web Crypto sont asynchrones en termes de signature — await crypto.subtle.digest('SHA-256', data) — mais le hachage consomme du CPU proportionnellement à la taille des données. Un Worker qui effectue HMAC-SHA256 à chaque requête pour vérifier un webhook y dépense un processeur réel, ce qui n'est pas pertinent pour les petites charges utiles mais est important au-dessus de quelques centaines de Ko.

Les expressions régulières sur de longues chaînes peuvent être étonnamment coûteuses. Les modèles avec un retour en arrière catastrophique (plusieurs quantificateurs imbriqués sur le même jeu de caractères) peuvent tripler le temps CPU sur des chaînes de quelques kilo-octets. Mesurez avec des chaînes réalistes, et non avec des entrées de 10 octets qui fonctionnent dans le test.

Que rechercher pour détecter la pression sur les ressources

wrangler tail renvoie cpuTime à chaque événement — le temps CPU mesuré en millisecondes pour cet appel. Enregistrer cette valeur de manière structurée nous permet de détecter les régressions : un déploiement qui augmente le CPU p99 de 3 ms à 12 ms signifie qu'une nouvelle opération synchrone a été introduite.

L'utilisation de la mémoire n'est pas directement exposée par l'invocation dans tail. La façon de détecter la pression de la mémoire avant de dépasser la limite est d'observer le comportement de l'isolat : si le runtime commence à créer de nouveaux isolats plus fréquemment que la normale pour le même PoP (visible par une augmentation des démarrages à froid), cela peut être le signe que les isolats sont supprimés plus tôt par le ramasse-miettes ou en raison de la pression de la mémoire.

La possibilité de réutiliser un isolat entre des requêtes dans le même PoP est une optimisation importante : le coût du démarrage à froid (décompression du script, initialisation de la V8, exécution du code du module de niveau supérieur) se produit une seule fois, et les requêtes suivantes réutilisent l'isolat déjà réchauffé. Les fermetures de modules qui restent en mémoire entre les requêtes constituent le mécanisme de mise en cache le plus rapide disponible dans Workers : plus rapide que KV, plus rapide que n'importe quelle sous-requête.

A lire aussi