Cloudflare Workers
D1
KV
R2
Bindings

Workers + D1 + KV + R2: compondo bindings num mesmo serviço

A composabilidade de bindings num único handler é o que diferencia Workers de um simples proxy — mas o limite de subrequisições cobra o custo de cada operação.

A proposta de valor dos Workers fica clara quando você vê o que é possível num único handler: buscar um registro no D1 com SQL, verificar uma entrada no KV, buscar um objeto do R2, chamar um serviço interno via Service Binding, e enfileirar trabalho assíncrono numa Queue — tudo dentro da mesma invocação, com cada binding injetado como propriedade do env e disponível com uma linha de código. Nenhuma SDK separada para instanciar, nenhuma configuração de rede para gerenciar, sem credenciais circulando em variáveis de ambiente. O wrangler.toml declara os bindings, o runtime os entrega. A questão é que cada uma dessas operações consome uma subrequisição do orçamento da invocação, e a conta aparece mais cedo do que parece.

O modelo de bindings e o que ele resolve

Bindings são a forma como o runtime de Workers conecta seu código às capacidades da plataforma sem expor credenciais ou configuração de rede. Quando você declara [[d1_databases]] no wrangler.toml com um database_id, o runtime injeta um objeto com a interface do D1 em env.DB. Quando você declara [[kv_namespaces]] com um id, o runtime injeta a interface do KV em env.CACHE. Não há token de autenticação, não há URL de endpoint, não há SDK de terceiros — o binding é uma chamada direta dentro da infraestrutura da Cloudflare.

Isso tem consequências para segurança e para operação. Um Worker comprometido não tem credenciais para exfiltrar — ele só pode operar os bindings que foram declarados para ele, com as permissões que esses bindings têm. Para um Worker que só precisa ler do KV, você declara o binding como read-only e o Worker literalmente não consegue escrever, independentemente do que o código tente fazer. Essa separação de capacidades é mais forte que uma checagem de permissão em código.

Para staging e produção, cada ambiente tem seus próprios binding IDs no wrangler.toml. O código não muda — env.DB continua sendo env.DB — mas em staging aponta para um banco D1 diferente, um namespace KV diferente, um bucket R2 diferente. Não há risco de código de staging tocar dados de produção porque os bindings são fisicamente separados.

O orçamento de subrequisições e como ele é consumido

O plano gratuito tem 50 subrequisições por invocação. O plano pago tem 1000. Cada operação que sai do isolate conta: fetch() para qualquer URL, env.KV.get(), env.KV.put(), env.DB.prepare().run(), env.BUCKET.get(), env.QUEUE.send(), env.SERVICE.fetch(). Uma chamada para env.DB.batch() com 5 queries conta como 1 subrequisição — esse detalhe é crítico.

Um handler típico de uma API que retorna um perfil de usuário enriquecido: busca o usuário no D1 por ID (1), busca as preferências no KV (2), verifica se há uma foto de perfil no R2 (3), chama um serviço de autorização via Service Binding (4). Total: 4 subrequisições na rota feliz. Escala para 1000 usuários concorrentes e ainda está dentro do limite — 4 subrequisições por invocação não é problema.

O problema surge com loops. Um handler que processa uma lista de itens e faz uma query D1 por item — o clássico N+1 — explode rapidamente. Cinquenta itens com uma query cada chegam no limite do plano gratuito na primeira chamada. Com 200 itens no plano pago, ainda está dentro de 1000, mas a latência acumulada de 200 queries D1 sequenciais vai ser de centenas de milissegundos. O erro quando o limite é atingido chega como um erro de rede na subrequisição que excedeu — sem mensagem clara no corpo da resposta ao cliente, apenas uma exceção no tail.

Otimizações concretas por binding

Para D1, o db.batch() é a otimização mais importante. Em vez de fazer prepare().run() em sequência para múltiplas queries, você passa um array de statements para batch() e recebe um array de resultados — com custo de 1 subrequisição total. Para queries que não dependem umas das outras (buscar configs de tipos diferentes, por exemplo), o batch elimina a latência sequencial e o gasto de subrequisições ao mesmo tempo.

Queries D1 paralelas — Promise.all([db.query1, db.query2]) — ainda contam como subrequisições separadas, mas executam em paralelo e a latência é determinada pela mais lenta, não pela soma. Use Promise.all() quando os resultados são independentes e você não precisa do batch por outra razão. Use batch() quando você quer consolidar o custo de subrequisição.

Para KV, o padrão mais valioso é o cache de módulo. O KV tem latência de rede — alguns milissegundos mesmo na melhor das hipóteses — e dados de configuração que mudam raramente não precisam ser relidos a cada invocação. Um módulo pode declarar uma variável no escopo global:

let cachedConfig = null; export default { async fetch(request, env) { if (!cachedConfig) { cachedConfig = JSON.parse(await env.CONFIG.get('app-config')); } // usa cachedConfig } }

Enquanto o isolate viver no PoP, as invocações subsequentes reutilizam o valor sem gastar uma subrequisição. O risco é staleness — se a configuração mudar, o isolate em cache ainda usa a versão antiga até ser descartado. Para dados que precisam ser quase-tempo-real, o KV com cacheTtl baixo ou a releitura por invocação é mais apropriada. Para feature flags e configs de infra que mudam raramente, o cache de módulo é eficiente.

Para R2, o binding suporta range requests — env.BUCKET.get(key, { range: { offset, length } }) — que permite buscar apenas a parte de um objeto que você precisa em vez do arquivo inteiro. Para arquivos grandes onde você precisa de um cabeçalho, metadados embutidos, ou as primeiras linhas de um CSV, o range request economiza memória e tempo de transferência. A resposta é uma ReadableStream que pode ser pipada direto para o cliente sem bufferizar.

Composição de múltiplos bindings na mesma requisição

O poder real aparece quando você precisa de múltiplos tipos de dados para construir uma resposta. Uma API de produto que retorna uma página de busca: query SQL no D1 para IDs que batem com o filtro, leitura paralela no KV para preços (que mudam frequentemente e vivem no KV por performance de leitura), e URL pré-assinada do R2 para a imagem principal de cada produto.

Essa composição funciona com naturalidade — env.DB, env.PRICES, env.ASSETS estão todos disponíveis no mesmo handler. O design que não funciona bem é fazer isso em sequência para cada item de uma lista. A versão correta: query D1 retorna 20 IDs, Promise.all() para as 20 leituras de KV em paralelo (20 subrequisições, mas em paralelo), Promise.all() para as 20 URLs do R2 (mais 20 subrequisições). Total: 41 subrequisições (1 D1 + 20 KV + 20 R2), dentro do limite pago de 1000, com latência determinada pelo mais lento dos 40 fetches paralelos, não pela soma de 41.

Monitorando o uso de subrequisições

O runtime não expõe contagem de subrequisições por invocação diretamente no tail. A abordagem é instrumentar: criar um wrapper simples que incrementa um contador a cada operação de binding e loga o total no final do handler. Exportado via Logpush, esse número permite detectar quando um deploy aumentou o consumo — antes de chegar num caso de borda que estoura o limite em produção.

Um handler que usa 500 subrequisições quando poderia usar 50 com batching e paralelismo corretos está gastando latência e orçamento sem necessidade. Redesenhar esse handler após um incidente em produção é mais custoso do que medir o consumo desde o início e corrigir enquanto o N ainda é pequeno.

Leia também

Workers + D1 + KV + R2: compondo bindings num mesmo serviço | Matheus Breguêz