Sua API provavelmente envia o mesmo JSON milhares de vezes por dia. Um cliente solicita GET /v1/products/42, recebe 18 KB de volta, pede novamente cinco minutos depois e obtém os mesmos 18 KB. Nada mudou. Você pagou pela largura de banda, pela serialização e pela leitura do banco de dados de qualquer forma.
O HTTP já resolveu este problema. O cabeçalho Cache-Control informa aos clientes por quanto tempo uma resposta permanece "fresca". O cabeçalho ETag fornece a eles uma "impressão digital" para verificar se ela mudou. Juntos, eles transformam solicitações repetidas em respostas 304 Not Modified com corpos vazios, e podem proteger suas escritas de atualizações perdidas como um bônus. As mesmas ideias impulsionam padrões do lado do cliente também; se você leu nosso guia sobre cache de respostas de API no React, este é o lado do servidor dessa história.
Este guia aborda as três camadas de cache HTTP, mostra a viagem de ida e volta do 304 passo a passo, descomplica no-cache vs no-store e termina com um código Express funcional. Você também verá como verificar tudo isso no Apidog, enviando cabeçalhos condicionais e validando o 304 por conta própria.
As três camadas de cache HTTP
O cache HTTP para APIs se divide em três decisões separadas. As equipes se metem em problemas quando as misturam.
Camada 1: Frescor. Por quanto tempo um cliente pode reutilizar uma resposta sem perguntar nada a você? Isso é Cache-Control: max-age=60. Por 60 segundos, o cliente serve a cópia em cache localmente. Zero tráfego de rede. Este é o cache hit mais barato possível e também o mais arriscado, porque o cliente não consegue detectar uma mudança até que o temporizador expire.
Camada 2: Validação. Uma vez que a resposta fica obsoleta, o cliente não precisa fazer o download novamente. Ele pergunta "isso mudou?" enviando a impressão digital que você forneceu anteriormente. Se o recurso não foi alterado, você responde com 304 Not Modified e sem corpo. ETag com If-None-Match é a versão precisa disso; Last-Modified com If-Modified-Since é a versão mais antiga, baseada em timestamp, com granularidade de um segundo.
Camada 3: Invalidação. Quando os dados mudam, como as cópias obsoletas "morrem"? Caches de clientes privados expiram por conta própria via max-age. Caches compartilhados e CDNs precisam de purgas explícitas, TTLs curtos ou diretivas como stale-while-revalidate que limitam a obsolescência.
O frescor economiza mais, a validação captura tudo o que o frescor perde, e a invalidação mantém ambos "honestos". A maioria das APIs precisa dos três.
Como funciona uma viagem de ida e volta 304 Not Modified
Aqui está o ciclo completo para um endpoint de produto, passo a passo.
Primeira solicitação. O cliente não tem nada em cache:
GET /v1/products/42 HTTP/1.1
Host: api.example.com
Primeira resposta. Você retorna o corpo mais os metadados de cache:
HTTP/1.1 200 OK
Cache-Control: private, max-age=60
ETag: "33a64df551425fcc55e4d42a148795d9f2"
Content-Type: application/json
Content-Length: 18432
O cliente armazena o corpo e o ETag. Pelos próximos 60 segundos, ele não o contata de forma alguma.
Segunda solicitação, após 60 segundos. A cópia está obsoleta, então o cliente revalida:
GET /v1/products/42 HTTP/1.1
Host: api.example.com
If-None-Match: "33a64df551425fcc55e4d42a148795d9f2"
Segunda resposta, recurso inalterado. Seu servidor compara o ETag recebido com o atual. Eles correspondem, então:
HTTP/1.1 304 Not Modified
Cache-Control: private, max-age=60
ETag: "33a64df551425fcc55e4d42a148795d9f2"
Nenhum corpo. Em vez de 18 KB, a resposta são algumas centenas de bytes de cabeçalhos. O cliente marca sua cópia em cache como "fresca" por mais 60 segundos e a serve. Se o produto tivesse mudado, você retornaria um 200 normal com o novo corpo e um novo ETag. Cobrimos o próprio código de status mais a fundo em nosso explicador 304 Not Modified; a versão curta é que um 304 é uma instrução de cache, não um erro.
A economia é simples. Um GET condicional ainda custa uma viagem de ida e volta, mais qualquer trabalho que compute o ETag atual. O que ele elimina é a transferência de payload e o re-parsing no lado do cliente. Para grandes endpoints de lista consultados por clientes móveis, isso rotineiramente reduz a saída da API em 60 a 90 por cento.
Diretivas Cache-Control que importam para APIs
O Cache-Control possui mais de uma dúzia de diretivas. Para APIs JSON, cinco carregam a maior parte do peso.
no-store vs no-cache. Este é o bug de cache mais comum em APIs de produção, e ele ocorre em ambas as direções. no-store significa “nunca grave isso em nenhum cache”. Use-o para payloads genuinamente sensíveis: tokens, dados bancários, PII que você não deve persistir. no-cache significa quase o oposto do que parece: caches PODEM armazenar a resposta, mas devem revalidar com a origem antes de cada reutilização. Combinado com um ETag, no-cache oferece economias de 304 em cada solicitação, garantindo que os clientes nunca mostrem dados obsoletos. Equipes que aplicam no-store em tudo “para estarem seguras” estão desabilitando completamente as solicitações condicionais e pagando o custo total do payload em cada chamada.
private. Marca a resposta como cacheável apenas pelo cliente do usuário final, nunca por caches compartilhados ou CDNs. Qualquer resposta que varie por usuário, que é a maior parte do tráfego de API autenticado, deve conter private. Sem ele, um proxy mal configurado pode servir os dados da conta de um usuário para outro.
max-age. Tempo de vida de frescor em segundos. Para APIs, pense pequeno: 30 a 300 segundos cobre a maioria dos endpoints de leitura. Você não está tentando eliminar solicitações por um dia; você está tentando absorver picos e loops de polling.
stale-while-revalidate. O meio-termo pragmático. Cache-Control: max-age=60, stale-while-revalidate=300 diz aos caches: sirva a cópia obsoleta por até 5 minutos extras, mas atualize-a em segundo plano. Os usuários obtêm respostas instantâneas; sua origem é atualizada logo depois. CDNs como Cloudflare e Fastly o suportam, assim como os navegadores.
Um padrão sensato para um endpoint de leitura autenticado se parece com isto:
Cache-Control: private, max-age=60, stale-while-revalidate=120
ETag: "9f8b2c41aa73e0d5"
A especificação completa do comportamento está na RFC 9111, que substituiu a RFC 7234 como o documento definitivo de cache HTTP. Quando uma CDN se comporta de uma maneira que o surpreende, essa RFC é onde a resposta reside.
ETags fortes vs fracas
Um ETag vem em dois "sabores", e o prefixo W/ os separa.
Um ETag forte (ETag: "33a64df551425fcc") promete igualdade byte a byte. Duas respostas com o mesmo ETag forte são idênticas, o que torna os ETags fortes seguros para requisições de faixa de bytes e necessários para controle de concorrência com If-Match.
Um ETag fraco (ETag: W/"33a64df551425fcc") promete equivalência semântica. Os bytes podem diferir, talvez a ordem dos campos tenha mudado ou um campo de timestamp tenha sido atualizado, mas o significado é o mesmo, então um cache pode manter sua cópia.
Onde isso pode te pegar: middleware de compressão. Nginx e alguns frameworks reescrevem ETags fortes para fracas quando compactam uma resposta em tempo real, porque os bytes compactados não correspondem mais aos originais. Se suas verificações de concorrência falharem misteriosamente atrás de um proxy, procure por um prefixo W/ que não estava lá quando seu servidor de aplicação enviou a resposta.
Use ETags fortes computados no corpo não compactado por padrão. Use os fracos apenas quando você intencionalmente serve representações variantes dos mesmos dados.
Gerando ETags: hash do corpo vs coluna de versão
Duas estratégias dominam, e a correta depende de onde o custo se encontra.
Hash do corpo da resposta. Serialize a resposta, faça o hash dela (MD5 ou SHA-1 servem aqui; isso é uma impressão digital, não um limite de segurança) e cite-o. É preciso por construção e não precisa de mudanças de esquema. A desvantagem: você constrói a resposta completa em cada solicitação, incluindo as 304s. Você economiza largura de banda, mas não carga de computação ou de banco de dados.
Coluna de versão ou updated_at. Derive o ETag de dados que você pode buscar de forma barata: ETag: "42-v17" do contador de versão da linha, ou um hash de updated_at. Agora, uma solicitação condicional custa uma pesquisa indexada em vez de serialização completa. A desvantagem: a versão deve ser incrementada a cada mudança que afete a resposta, incluindo mudanças em tabelas unidas. Erre uma e você servirá 304s obsoletos, o que é o pior bug de cache porque é invisível.
Comece com o hashing do corpo. É correto por padrão. Mova endpoints "quentes" para ETags baseados em versão quando a análise de perfil mostrar que o custo de serialização importa.
ETags para concorrência otimista: If-Match e 412
A mesma impressão digital que economiza largura de banda nas leituras evita atualizações perdidas nas escritas.
O problema da atualização perdida: dois administradores carregam o produto 42 ao mesmo tempo. O Administrador A muda o preço e salva. O Administrador B corrige um erro de digitação e salva 30 segundos depois, sobrescrevendo a mudança de preço de A com o preço obsoleto que B carregou. Ninguém vê um erro. Os dados estão silenciosamente incorretos.
A solução é tornar cada atualização condicional à versão que o cliente viu por último:
PUT /v1/products/42 HTTP/1.1
If-Match: "33a64df551425fcc55e4d42a148795d9f2"
Content-Type: application/json
O servidor compara If-Match com o ETag atual do recurso. Se houver correspondência: aplica a atualização, retorna 200 com um novo ETag. Se não houver correspondência, alguém chegou lá primeiro: rejeita com 412 Precondition Failed e não toca nos dados. O cliente então busca novamente, reaplica sua alteração na versão "fresca" e tenta novamente. APIs estritas vão além e retornam 428 Precondition Required em qualquer PUT que omita If-Match, tornando a verificação de segurança obrigatória.
Isso não custa quase nada para adicionar uma vez que os ETags existem, e converte um bug silencioso de corrupção de dados em um status HTTP explícito e que pode ser tentado novamente.
O que CDNs e proxies fazem com esses cabeçalhos
privateexclui uma resposta do cache da CDN completamente;s-maxage=600define um TTL específico da CDN mais longo ou mais curto que omax-agedo navegador.- A maioria das CDNs revalida com sua origem usando requisições condicionais. Se sua origem responder If-None-Match com um 304, a CDN atualiza sua cópia armazenada sem puxar o corpo. ETags também tornam sua CDN mais barata.
- Sempre confirme se seu framework envia
Varycorretamente. Uma API que serve JSON e CSV de uma mesma URL precisa deVary: Accept, ou um cache compartilhado entregará CSV a um cliente JSON. - Fique atento a proxies que enfraquecem ETags via compressão, conforme abordado acima.
Exemplo Express: retornando um ETag e tratando If-None-Match
O Express define ETags fracos por conta própria, mas o tratamento manual oferece ETags fortes, além do caminho de escrita 412:
import crypto from "node:crypto";
import express from "express";
const app = express();
app.use(express.json());
function etagFor(payload) {
const hash = crypto.createHash("sha1")
.update(JSON.stringify(payload))
.digest("hex");
return `"${hash}"`;
}
app.get("/v1/products/:id", async (req, res) => {
const product = await db.products.find(req.params.id);
const etag = etagFor(product);
res.set("Cache-Control", "private, max-age=60, stale-while-revalidate=120");
res.set("ETag", etag);
if (req.get("If-None-Match") === etag) {
return res.status(304).end(); // impressão digital corresponde: sem corpo
}
res.json(product);
});
app.put("/v1/products/:id", async (req, res) => {
const product = await db.products.find(req.params.id);
const currentEtag = etagFor(product);
const ifMatch = req.get("If-Match");
if (!ifMatch) {
return res.status(428).json({ error: "If-Match header required" });
}
if (ifMatch !== currentEtag) {
return res.status(412).json({ error: "Resource changed since you fetched it" });
}
const updated = await db.products.update(req.params.id, req.body);
res.set("ETag", etagFor(updated));
res.json(updated);
});
Observe que o ramo 304 ainda envia os cabeçalhos Cache-Control e ETag. De acordo com a RFC 9111, um 304 atualiza os metadados da resposta armazenada, então reenvie tudo o que o cliente precisa para manter sua cópia "fresca".
Verificando o comportamento do cache no Apidog
Um código que parece correto ainda pode ter o cache errado uma vez que middlewares e proxies são envolvidos. Teste no nível HTTP, não no nível do código.
- Envie
GET /v1/products/42e abra o painel de cabeçalhos da resposta. Confirme queETageCache-Controlestão presentes e que o ETag está entre aspas. Copie o valor do ETag. - Na mesma requisição, adicione um cabeçalho
If-None-Matchcom o valor copiado e envie novamente. Você deve receber um 304 com um corpo vazio. Se você ainda receber um 200, sua camada de validação não está comparando impressões digitais. - Altere o registro, reenvie e confirme que você voltou a um 200 com um ETag "fresco".
Para manter isso funcionando após cada implantação, integre o mesmo fluxo em um cenário de teste. Encadeie duas requisições: a primeira extrai o ETag dos cabeçalhos da resposta para uma variável, a segunda o envia de volta como If-None-Match e verifica se o status é 304 e o corpo está vazio. Adicione uma terceira etapa para o caminho de escrita: envie um PUT com um valor If-Match intencionalmente obsoleto como "deadbeefcafe1234" e verifique o 412. Nosso guia sobre asserções de API aborda a sintaxe de asserção para códigos de status e cabeçalhos.
Execute esse cenário na CI e uma atualização de middleware que remove silenciosamente seus ETags se tornará um pipeline falho em vez de uma conta de largura de banda. Baixe o Apidog gratuitamente e construa o cenário contra seus próprios endpoints; leva mais tempo para ler sobre isso do que para montá-lo.
FAQ
Qual a diferença entre no-cache e no-store?
no-store proíbe completamente o cache: nada é gravado em disco ou memória, então cada requisição baixa a resposta completa. no-cache permite armazenar, mas força a revalidação antes de cada reutilização, de modo que, emparelhado com um ETag, ainda produz respostas 304 e economias de payload. Use no-store apenas para dados sensíveis. Usá-lo em todos os lugares é o erro de Cache-Control mais caro que uma equipe de API pode cometer.
ETags funcionam com POST?
Na maioria das vezes não, e por design. ETags descrevem o estado de um recurso em uma URL, e POST geralmente cria algo novo em vez de ler um estado estável. Na prática, os caches não armazenam respostas POST. Os cabeçalhos condicionais que importam para escritas são If-Match em PUT, PATCH e DELETE, onde o ETag protege contra atualizações perdidas. Se você está tentado a armazenar respostas POST em cache, isso geralmente é um sinal de que a operação deveria ser um GET.
Uma resposta 304 torna minha API mais rápida?
Isso torna as transferências menores, o que não é a mesma coisa. O servidor ainda recebe a requisição, executa a autenticação e calcula o ETag atual, então as economias de CPU na origem dependem de quão barato você deriva essa impressão digital. Os ganhos aparecem na largura de banda, bateria do celular e tempo de renderização em redes lentas. Meça antes e depois; nosso guia de teste de desempenho de API mostra como comparar latência e throughput para que você possa provar a diferença em vez de adivinhar.
Devo usar ETag ou Last-Modified?
Envie ambos quando puder. O ETag é mais preciso: ele detecta mudanças em submúltiplos de segundo e diferenças no nível do conteúdo que um timestamp perde, e If-None-Match tem prioridade sobre If-Modified-Since quando ambos chegam. Last-Modified continua útil como um fallback para clientes mais antigos e como uma heurística que alguns caches usam para estimar o frescor. Se você enviar apenas um, envie o ETag.
