O limite de CPU de 10ms no plano gratuito de Workers assusta quem lê pela primeira vez. Dez milissegundos parecem ridiculamente pouco para qualquer coisa útil. A consequência é que muitos times sobem imediatamente para o plano pago pelos 30 segundos de CPU — sem entender que o modelo de medição é fundamentalmente diferente de um servidor tradicional, e que a maioria dos Workers tem sobra de CPU mesmo nos 10ms gratuitos. O limite de 128MB de memória, por outro lado, é subestimado de forma sistemática e é responsável por uma categoria inteira de falhas silenciosas em produção.
Como o timer de CPU realmente funciona
O runtime de Workers mede "CPU time" — tempo em que o thread JavaScript está ativamente executando código. O timer para durante qualquer operação de I/O assíncrona: await fetch(), await env.KV.get(), await db.query(), await env.BUCKET.get(). Durante esses awaits, o isolate está idle, e o timer não avança.
O efeito prático é grande. Um Worker que faz cinco chamadas fetch() sequenciais para APIs externas, cada uma levando 100ms de latência de rede, tem um wall-clock time de 500ms mas usa talvez 6ms de CPU — apenas a serialização dos headers, o parsing do JSON de resposta, e a lógica de negócio entre as chamadas. Para a maioria dos Workers que são essencialmente orquestradores de I/O, o limite de 10ms é generoso.
O que gasta CPU de verdade são operações sincronamente intensas: regex aplicado a strings grandes, JSON.stringify() de objetos com hierarquia profunda ou arrays grandes, operações criptográficas mesmo quando a API é assíncrona (o trabalho de hashing ocorre em CPU durante a execução), e encoding/decoding de base64 em binários grandes. Uma Worker que recebe um payload JSON de 500KB e faz JSON.parse() nele vai gastar tempo de CPU mensurável nessa operação — o parsing é síncrono.
No plano pago, o limite sobe para 30 segundos de CPU, o que é suficiente para casos de uso computacionalmente intensos: compressão, geração de imagem, inferência de modelos pequenos. Mas mesmo no pago, operações de CPU acima de alguns segundos são sintoma de design inadequado para o edge — Workers foram otimizados para latência baixa, não para processamento pesado.
O timer de CPU não é o que mata Workers em produção
O que realmente derruba Workers em produção sem aviso claro é a memória. O limite de 128MB parece razoável até você entender o que conta: o script descomprimido no heap do V8, todos os closures de módulo que existem no escopo global desde o início da invocação, mais tudo que é alocado durante o processamento da requisição em curso.
O script em si pode consumir mais do que você imagina. Um Worker de 500KB comprimido pode ocupar 3-4MB depois de descomprimido e parseado pelo V8. Dependências importadas no escopo de módulo — bibliotecas de validação, parsers, SDKs — ficam na memória enquanto o isolate vive, mesmo que não sejam usadas na requisição atual. O escopo global é compartilhado entre requisições que o mesmo isolate processa sequencialmente no mesmo PoP.
A alocação que mais frequentemente dispara o limite é response.arrayBuffer() ou request.arrayBuffer(). Chamar esse método em uma resposta de 40MB aloca 40MB de heap imediatamente. Se o Worker então cria mais estruturas de dados a partir desse buffer — objetos parseados, cópias transformadas — o uso de memória pode ultrapassar 128MB antes do processamento terminar. O runtime mata o isolate nesse momento, o cliente recebe um erro 1101, e não há stack trace — apenas um erro de runtime reportado como exceção genérica no wrangler tail.
Streaming como estratégia de sobrevivência de memória
A solução para o problema de memória com payloads grandes é nunca materializar o conteúdo inteiro como buffer. Em vez de response.arrayBuffer(), use response.body — que é um ReadableStream — e processe os dados em chunks com TransformStream.
A API de streams do Workers segue a especificação de WHATWG Streams, a mesma disponível em browsers modernos. Um TransformStream tem um writable e um readable: você conecta o readable da resposta upstream ao writable do transform, e conecta o readable do transform ao response que você envia ao cliente. Os chunks fluem pela pipeline sem nunca existir inteiros na memória ao mesmo tempo.
Essa arquitetura tem uma consequência importante: você não pode mais ler o conteúdo completo para tomar decisões que dependem do arquivo inteiro antes de começar a responder. Para casos onde você precisa de acesso aleatório — processar um CSV que requer ordenação global, por exemplo — Workers não é o lugar certo. Para transformações que operam chunk por chunk — recompressão, substituição de texto, filtering de linhas — a pipeline de streams resolve sem pressão de memória.
Para uploads indo para R2, o binding aceita diretamente um ReadableStream:
await env.BUCKET.put(key, request.body, { httpMetadata }) — sem bufferizar nada. O stream de corpo da requisição vai diretamente para o R2 em chunks.
Operações que surpreendem pelo uso de CPU
Base64 encoding e decoding são operações CPU-intensas proporcionais ao tamanho do dado — e o resultado ocupa 33% a mais de memória. Se você está transportando binários, vale questionar se o encoding é necessário; muitos usos de base64 são herança de limitações HTTP que não se aplicam mais com fetch e tipos binários nativos.
Operações de hashing via Web Crypto API são assíncronas na assinatura — await crypto.subtle.digest('SHA-256', data) — mas o hashing consome CPU proporcional ao tamanho do dado. Um Worker que faz HMAC-SHA256 em cada requisição para verificar um webhook está gastando CPU real nisso, irrelevante para payloads pequenos mas significativo acima de alguns centenas de KB.
Regex em strings longas pode ser surpreendentemente caro. Padrões com backtracking catastrófico — múltiplos quantificadores aninhados sobre o mesmo conjunto de caracteres — podem triplicar o tempo de CPU em strings de alguns kilobytes. Meça com strings realistas, não com inputs de 10 bytes que funcionam no teste.
O que observar para detectar pressão de recursos
O wrangler tail retorna cpuTime em cada evento — o tempo de CPU medido em milissegundos para aquela invocação. Logar esse valor estruturadamente permite detectar regressões: um deploy que aumenta p99 de CPU de 3ms para 12ms significa que alguma operação síncrona nova foi introduzida.
O uso de memória não é exposto diretamente por invocação no tail. A forma de detectar pressão de memória antes de estourar o limite é observar o comportamento do isolate: se o runtime começa a criar novos isolates com mais frequência do que o normal para o mesmo PoP — visível como aumento nos cold starts — pode ser sinal de que isolates estão sendo descartados mais cedo pelo coletor de lixo ou por pressão de memória.
A capacidade de um isolate ser reutilizado entre requisições no mesmo PoP é uma otimização importante: o custo do cold start — descomprimir o script, inicializar o V8, executar o código de módulo de nível superior — acontece uma vez, e as requisições subsequentes reutilizam o isolate já aquecido. Closures de módulo que ficam em memória entre requisições são o mecanismo de cache mais rápido disponível em Workers — mais rápido que KV, mais rápido que qualquer subrequisição.
Leia também
- Cloudflare Workers em produção: o que muda depois do hello world
- D1 em produção: performance, limites e o que não escala sozinho
- Cloudflare Workers: Guia Prático para Serverless Edge Computing
- Durable Objects em produção: o que a conta vai parecer e os limites que surpreendem
- WebAssembly no edge: por que iniciar rápido e isolado importa
- Workers + D1 + KV + R2: compondo bindings num mesmo serviço