Cloudflare Workers
Produção
Observabilidade
Performance
Serverless

Cloudflare Workers em produção: o que muda depois do hello world

O ambiente local de Workers é enganosamente confortável — produção cobra um preço diferente em logs, memória e limites de subrequisições.

Desenvolver com Cloudflare Workers localmente é uma das experiências mais fluidas que serverless tem a oferecer hoje. O wrangler dev sobe em segundos, os logs aparecem no terminal, e tudo parece funcionar exatamente como você espera. O problema é que esse conforto mascara diferenças fundamentais que só aparecem quando o código chega ao edge de verdade. Muitos times descobrem tarde que console.log em produção vai para lugar nenhum a menos que haja alguém ativamente fazendo wrangler tail, que bufferizar uma resposta de 50MB mata o isolate silenciosamente, e que aquele handler que faz 15 queries no D1 mais 10 leituras no KV é uma bomba-relógio contra o limite de subrequisições.

O que acontece com seus logs em produção

Localmente, o wrangler dev exibe cada console.log no terminal em tempo real. Em produção, os isolates V8 do Cloudflare não têm um processo persistente para capturar essa saída — cada invocação roda em isolamento, executa, e desaparece. Os logs só ficam acessíveis via wrangler tail, que abre uma sessão de streaming com os Workers em produção e retransmite metadados de requisição, saída de console.log, exceções não capturadas e duração de CPU.

O ponto crítico: essa sessão não persiste. Se não há ninguém fazendo tail quando um erro acontece, o log se perde. Para retenção, a solução é o Logpush — que exporta logs para R2, S3, Datadog ou Splunk a US$ 0,05 por milhão de linhas para R2. Mas o Logpush trabalha com os campos estruturados que o runtime do Worker emite, não com texto livre de console.log. A consequência prática é que logs úteis em produção precisam ser estruturados desde o início: JSON com campos como requestId, duration, statusCode, error — campos que tanto o wrangler tail consegue filtrar quanto o Logpush consegue exportar com fidelidade.

O padrão que funciona é criar um wrapper de logging mínimo no início do projeto, antes de precisar dele. Algo que serializa para JSON e escreve em console.log, com um campo level diferenciando info de error. Quando o Logpush entra, esses campos ficam disponíveis para filtros e alertas no destino.

Memória: 128MB é menor do que parece

O limite de 128MB por isolate inclui o script descomprimido no V8, todos os closures de módulo, e o heap inteiro da invocação em curso. Não é 128MB para "seus dados" — é 128MB para tudo, incluindo o próprio runtime.

O erro mais comum é chamar response.arrayBuffer() em respostas grandes. Um arquivo de 30MB baixado de outro serviço para ser processado e repassado ocupa 30MB de heap instantaneamente. Se o processamento cria mais alocações intermediárias, o isolate ultrapassa 128MB e é morto — sem exceção capturável, sem resposta ao cliente, apenas um erro 1101 do lado de fora.

A solução é usar response.body como ReadableStream e processar via TransformStream. Em vez de bufferizar e transformar, você cria um pipeline onde os chunks fluem: lidos do upstream, transformados em trânsito, escritos no response sem nunca existir inteiros na memória. Para respostas maiores que 1MB, a presunção deve ser streaming; bufferização deve ser uma escolha explícita e justificada, não o caminho padrão.

O mesmo raciocínio se aplica a uploads. O corpo máximo de requisição é 100MB, mas request.arrayBuffer() tenta alocar tudo de uma vez. Para uploads grandes, o processamento deve ser feito em stream também, ou o arquivo deve ir direto para R2 via put() que aceita um ReadableStream.

Subrequisições: o limite que aparece na pior hora

Cada fetch(), cada operação no KV, cada query no D1, cada leitura no R2 conta como uma subrequisição. No plano pago, o limite é 1000 por invocação. No gratuito, 50.

Um handler que parece razoável — busca o usuário no D1, lê suas preferências no KV, puxa o documento do R2, chama uma API externa, salva o resultado no D1 — já está em 5 subrequisições na rota feliz. Se esse handler entra num loop porque processa uma lista de itens, o contador sobe rápido. Cinquenta itens com duas operações cada já chegam perto de 100. Um bug que faz N+1 queries no D1 — buscando cada registro filho individualmente em vez de com um JOIN — pode facilmente estourar 1000 antes do fim de uma única requisição complexa.

O erro quando o limite é atingido não é óbvio: o Worker recebe um erro de rede na subrequisição que excedeu o limite, o que pode ser confundido com instabilidade da rede ou do serviço downstream. O diagnóstico correto exige ver os logs de exceção via wrangler tail e correlacionar com o padrão de chamadas do handler.

Mitigação: use db.batch() no D1 para agrupar múltiplas queries numa única subrequisição. Leia configurações do KV uma vez na inicialização do módulo e cache no escopo global — o isolate pode ser reutilizado entre requisições no mesmo PoP, e a leitura do KV que aconteceu na primeira invocação não precisa repetir nas seguintes enquanto o isolate viver.

Secrets, ambientes e o wrangler.toml que vai para o repositório

Variáveis declaradas em wrangler.toml sob [vars] são texto claro no arquivo de configuração, que normalmente vai para o repositório. Para qualquer valor sensível — chaves de API, tokens de banco, segredos de webhook — a única opção correta são Workers Secrets, declarados como [secrets] no wrangler.toml e armazenados via wrangler secret put. O valor é criptografado em repouso e injetado em runtime como propriedade do env, sem aparecer em nenhum log ou artefato de build.

O wrangler.toml de um serviço que vai para produção deve ter ambientes explícitos: [env.staging] e [env.production] com seus próprios bindings, rotas e secrets separados. Misturar staging e produção no mesmo conjunto de bindings é um acidente esperando para acontecer — especialmente quando D1 e KV têm dados reais em produção e dados de teste em staging. A separação de ambientes no wrangler.toml também permite que o pipeline de CI faça deploy para staging automaticamente e exija aprovação manual para produção, sem nenhuma lógica adicional no script de deploy.

O que uma configuração mínima de produção parece

Um wrangler.toml pronto para produção referencia compatibility_date explícito (para não receber breaking changes do runtime sem aviso), define [observability] com enabled = true para expor métricas básicas no dashboard, e separa [env.staging] de [env.production] com rotas distintas. Secrets são listados por nome sem valor — o valor existe apenas no Cloudflare, nunca no repo.

O handler principal tem um try/catch no nível mais alto que captura qualquer exceção não tratada, loga como JSON estruturado com console.error, e retorna uma resposta HTTP 500 com um requestId rastreável. Sem esse boundary, uma exceção inesperada pode retornar um 200 com body truncado ao cliente enquanto o erro real aparece apenas no tail — e somente se alguém estiver olhando.

Leia também

Cloudflare Workers em produção: o que muda depois do hello world | Matheus Breguêz