Em junho de 2022, a IETF publicou a RFC 9111 —
HTTP Caching, atualizando e substituindo a
RFC 7234 (de 2014). Editor principal: Mark Nottingham,
australiano que escreveu sobre cache HTTP por mais de
duas décadas e foi chair do IETF HTTP Working Group.
A RFC 9111 não muda dramaticamente o funcionamento de
cache; consolida prática conhecida em texto rigoroso,
adiciona algumas diretivas modernas
(stale-while-revalidate,
immutable), e simplifica regras
complexas. É a leitura canônica em 2026 sobre o
assunto.
HTTP cache é a camada mais antiga e mais robusta de
caching que existe na web. Browsers, CDNs, proxies
reversos (Varnish, NGINX, Apache), e até o próprio
cliente HTTP em código (HttpClient em .NET, requests/
httpx em Python) — todos honram cabeçalhos de cache
HTTP automaticamente. Configurar bem
Cache-Control e ETag em uma
API moderna é gratuito do ponto de vista de
infraestrutura, e pode reduzir tráfego ao origin em
ordens de magnitude. Configurar mal é fonte canônica de
bugs sutis: usuários veem dado obsoleto, ou —
incidente clássico — usuário A vê dado de usuário B
cacheado por engano.
Apesar de ser camada antiga e madura, surpreendentemente
poucos engenheiros sêniores articulam HTTP cache com
precisão. Cache-Control: public, max-age=300
é a parte fácil; entender o que Vary faz,
quando usar ETag forte vs fraco, qual é a
semântica de stale-while-revalidate, e por
que private em endpoint autenticado pode
vazar — essa é a parte que separa quem usa de quem
entende.
Este conceito articula a especificação em prática.
Anatomia de cabeçalhos, semântica precisa de cada
diretiva, conditional requests com 304 Not
Modified, validação por ETag, e os
anti-padrões que vazam dado em sistemas autenticados.
O conceito 10 (CDN) detalha como essa camada se
compõe com a infraestrutura externa.
O modelo conceitual
HTTP cache opera sobre três variáveis principais que o servidor controla via cabeçalhos da resposta.
Cacheabilidade: o servidor diz se a
resposta pode ser cacheada (public,
private, ou no-store) e por
quanto tempo (max-age,
s-maxage).
Identidade: o servidor fornece um
identificador da versão (ETag) ou
timestamp (Last-Modified) que permite ao
cache verificar mais tarde se o recurso mudou.
Variabilidade: o servidor declara quais
cabeçalhos da request afetam a resposta
(Vary) — fundamental para que cache
compartilhado não sirva resposta errada para clientes
diferentes.
Quando uma request chega a um cache, ele segue
sequência: tem entrada para esse URL? Se sim, ela é
fresca (dentro do TTL)? Se sim, serve direto
(cache hit, latência mínima). Se está expirada, faz
conditional request ao origin (envia
If-None-Match com ETag, ou
If-Modified-Since); o origin responde
304 Not Modified se nada mudou (cache
atualiza expiração, serve dado local) ou
200 OK com novo body se mudou (cache
substitui).
Esse ciclo torna a maioria das requisições muito mais
baratas: quando o recurso não mudou, o origin manda
304 com cabeçalhos vazios — talvez 200
bytes — em vez do JSON de 50 KB. Bandwidth, CPU de
serialização, e latência de transmissão, todos
reduzidos.
Cache-Control — as diretivas que importam
Cache-Control é o cabeçalho central. As
diretivas mais usadas:
public — qualquer cache
(browser, CDN, proxy compartilhado) pode armazenar.
Apropriado para conteúdo público sem
personalização.
private — apenas o cache
do usuário final (browser) pode armazenar; CDNs e
proxies compartilhados não. Apropriado para conteúdo
personalizado por usuário.
no-cache — pode ser
armazenado, mas precisa revalidar antes de usar
(sempre If-None-Match ao origin).
Combinação útil com ETag para entradas que mudam
frequentemente mas se beneficiam de validação rápida.
no-store — não armazene
em lugar nenhum. Para dado verdadeiramente sensível
(token de auth em response body, dados de pagamento).
max-age=N — segundos que
a resposta pode ser servida sem revalidar. Aplicada a
todos os caches (incluindo browser).
s-maxage=N — sobrescreve
max-age para caches compartilhados (CDN,
proxy). Permite ter TTL diferente no browser e no CDN.
must-revalidate —
cache deve revalidar com origin se o recurso expirou,
e não pode servir versão expirada se origin estiver
indisponível.
stale-while-revalidate=N
— pode servir versão expirada por até N segundos
enquanto revalida em background. Ótimo para latência
perceptível: o usuário recebe imediatamente conteúdo
"ligeiramente velho" enquanto o cache busca atualização.
stale-if-error=N — pode
servir versão expirada por N segundos se origin
retornar erro. Resiliência baseada em cache.
immutable — o conteúdo
nunca mudará na vida do TTL; navegadores podem
pular revalidação mesmo se usuário fizer reload.
Apropriado para assets versionados (URL com hash do
conteúdo).
Receitas comuns por tipo de recurso
A combinação certa varia por tipo de recurso. As receitas canônicas:
Asset estático versionado
(/static/app.abc123.js):
Cache-Control: public, max-age=31536000,
immutable. Um ano de TTL, jamais revalidação.
Quando o conteúdo mudar, a URL muda (novo hash), e o
cache serve a versão correta automaticamente. Esse é
o padrão moderno de bundlers (webpack, vite, esbuild)
gerarem URLs com hash.
Asset estático sem versionamento
(/favicon.ico):
Cache-Control: public, max-age=86400,
stale-while-revalidate=604800. Um dia de TTL,
mas cache pode servir até uma semana de versão velha
enquanto revalida em background. Usuários sempre veem
conteúdo recente, sem latência de revalidação.
API JSON pública dinâmica
(/api/produtos):
Cache-Control: public, max-age=60,
stale-while-revalidate=300 + ETag:
"...". Cache de 1 minuto, com fallback de 5 min
durante revalidação, e ETag para conditional requests.
API JSON personalizada
(/api/me):
Cache-Control: private, max-age=0,
must-revalidate + ETag: "...".
Apenas o browser do usuário cacheia; sempre revalida;
ETag torna revalidação barata.
Resposta sensível (token, sessão):
Cache-Control: no-store. Nunca armazene.
Combinar com private é redundante mas
defensivo.
ETag e validation — conditional requests
ETag é um identificador opaco da versão do
recurso. O servidor calcula (hash do body, número de
versão, timestamp) e envia em
ETag: "abc123". Cliente armazena junto com
o response. Em request seguinte, cliente envia
If-None-Match: "abc123". Servidor compara;
se ainda é a mesma versão, responde 304 Not
Modified com body vazio. Se mudou, responde
200 com novo body e novo ETag.
O ganho é dramático para recursos que mudam raramente: payload de 50 KB vira resposta de poucos bytes. Em sistemas com muito tráfego de leitura, ETag pode reduzir bandwidth ao origin em 90%+.
Strong ETag vs Weak ETag. Strong
(ETag: "abc123") significa "byte-por-byte
idêntico". Weak (ETag: W/"abc123") significa
"semanticamente equivalente, talvez bytes
diferentes". Use weak quando o servidor faz
transformações idempotentes (ex.: timestamp
diferente em metadata mas conteúdo igual). Strong é
mais restritivo e raramente é necessário ser
restritivo demais.
Calcular ETag tem custo. Se você calcula hash
criptográfico do body inteiro a cada request, o ganho
de cache pode ser comido pela CPU de hash. Padrões
mais baratos: número de versão da entidade no banco
(updated_at + row_version),
ou hash de campos específicos. Para arquivos
estáticos, o servidor (NGINX, Apache, IIS) calcula
automaticamente baseado em inode + size + mtime.
Vary — quando o cache compartilhado precisa discriminar
Vary é o cabeçalho que mais confunde — e
o que mais vaza dado quando configurado errado. A
semântica: "esta resposta varia conforme os valores
desses cabeçalhos da request". Cache compartilhado deve
manter entradas separadas por cada combinação de
valores desses cabeçalhos.
Casos típicos:
Vary: Accept-Encoding —
cache mantém versão gzipada e versão sem compressão
separadas. Universalmente apropriado em endpoints que
negociam compressão.
Vary: Accept-Language —
cache mantém versões por idioma. Apropriado em sites
multilíngues.
Vary: Authorization —
cache mantém versões por valor do header
Authorization. Cuidado: cada usuário
autenticado tem header diferente, então cache
compartilhado vai ter milhões de entradas. Em
compartilhado raramente ajuda; melhor é
private.
Vary: User-Agent —
cache mantém versão por user agent. Quase nunca o que
você quer — milhares de variantes de UA quebram cache
hit rate.
A regra: declare todos os headers que afetam o
conteúdo. Esquecer um vaza resposta entre requests
diferentes — usuário A vê resposta gerada para
usuário B porque o cache compartilhado considerou as
duas requests "iguais". Esse é um dos bugs mais
perigosos da web, e é comum em sistemas que adicionam
personalização sem atualizar Vary.
Implementação por linguagem
Frameworks modernos têm suporte a HTTP cache de formas distintas. Conhecer o idiomático evita reinventar a roda.
// startup
builder.Services.AddOutputCache(options => {
options.AddBasePolicy(builder => builder
.Expire(TimeSpan.FromMinutes(1))
.SetVaryByHeader("Accept-Encoding"));
options.AddPolicy("ProductPolicy", builder => builder
.Expire(TimeSpan.FromMinutes(5))
.SetVaryByQuery("category", "page")
.Tag("products"));
});
var app = builder.Build();
app.UseOutputCache();
// endpoint com OutputCache (cache no servidor + cabeçalhos para client)
app.MapGet("/api/produtos/{id}",
async (Guid id, IProdutoRepository repo, HttpContext ctx) => {
var produto = await repo.ObterAsync(id);
if (produto is null) return Results.NotFound();
// ETag forte baseado no UpdatedAt
var etag = $"\"{produto.UpdatedAt:O}\"";
ctx.Response.Headers.ETag = etag;
// verificar If-None-Match
if (ctx.Request.Headers.IfNoneMatch == etag)
return Results.StatusCode(304);
// Cache-Control para clientes
ctx.Response.Headers.CacheControl = "public, max-age=60, stale-while-revalidate=300";
return Results.Ok(produto);
})
.CacheOutput("ProductPolicy");
// invalidação por tag em endpoint de update
app.MapPut("/api/produtos/{id}", async (Guid id, AtualizarCmd cmd,
IProdutoRepository repo, IOutputCacheStore cache) => {
await repo.AtualizarAsync(id, cmd);
await cache.EvictByTagAsync("products", default);
return Results.NoContent();
});
ASP.NET Core 7+ tem OutputCache que
combina cache server-side (em memória ou Redis)
com cabeçalhos para clientes. Tags facilitam
invalidação. Para casos simples, atributo
[ResponseCache] emite só os cabeçalhos.
from fastapi import FastAPI, Response, Request, status
from datetime import datetime, timedelta
import hashlib, json
app = FastAPI()
@app.get("/api/produtos/{id}")
async def obter_produto(id: str, request: Request, response: Response):
produto = await repo.obter(id)
if produto is None:
raise HTTPException(404)
# ETag forte (hash do conteúdo)
body = produto.to_dict()
etag = '"' + hashlib.sha256(
json.dumps(body, sort_keys=True).encode()
).hexdigest()[:16] + '"'
# If-None-Match → 304
if request.headers.get("If-None-Match") == etag:
return Response(status_code=status.HTTP_304_NOT_MODIFIED)
response.headers["ETag"] = etag
response.headers["Cache-Control"] = "public, max-age=60, stale-while-revalidate=300"
response.headers["Vary"] = "Accept-Encoding"
return body
# alternativa: asgi-cache (server-side via Redis ou memória)
# pip install asgi-cache
# from asgi_cache import CacheMiddleware
# app.add_middleware(CacheMiddleware, ttl=60, namespace="app")
FastAPI não tem cache HTTP integrado; você manipula response.headers manualmente, ou usa middleware como asgi-cache. Verboso mas explícito — você sabe exatamente o que sai em cabeçalhos.
// middleware que adiciona cabeçalhos de cache
func CacheControl(maxAge int, swr int) func(http.Handler) http.Handler {
val := fmt.Sprintf("public, max-age=%d, stale-while-revalidate=%d", maxAge, swr)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", val)
w.Header().Set("Vary", "Accept-Encoding")
next.ServeHTTP(w, r)
})
}
}
// handler com ETag e 304
func (h *Handlers) ObterProduto(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
produto, err := h.repo.Obter(r.Context(), id)
if err != nil {
http.NotFound(w, r); return
}
// ETag baseado em version do banco
etag := fmt.Sprintf(`"%d"`, produto.Version)
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("ETag", etag)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(produto)
}
// rota
r.With(CacheControl(60, 300)).Get("/api/produtos/{id}", h.ObterProduto)
Go é o mais explícito. Sem framework de cache
HTTP universal — cada time monta o que precisa.
Bibliotecas como github.com/victorspringer/http-cache
oferecem cache server-side, mas não são tão ubíquas
quanto OutputCache do .NET.
O incidente clássico — vazamento de dado entre usuários
O bug mais perigoso de HTTP cache em sistema
autenticado é vazamento. Cenário canônico:
desenvolvedor adiciona cache em endpoint
/api/me (perfil do usuário) sem pensar.
O cache compartilhado (CDN, proxy reverso) armazena a
resposta como "cache hit para essa URL", sem
considerar que o conteúdo é específico do usuário. O
próximo request a /api/me de qualquer
usuário recebe a resposta cacheada — que é do usuário
anterior. Tokens, dados pessoais, e-mails, tudo vaza.
As três defesas estruturais:
1. private em endpoints
autenticados. Sempre que o response tem
personalização por usuário, use
Cache-Control: private. Apenas o cache do
browser do usuário guarda; CDN/proxy não.
2. Vary: Authorization em
último caso. Se o conteúdo de fato precisa
ser cacheado em compartilhado (raro), declare
Vary: Authorization. Cada usuário terá
entrada separada — efetivamente desabilita o cache
compartilhado para o endpoint, mas evita vazamento.
3. Tests automatizados. Toda equipe
séria tem teste que verifica:
/api/me com token A retorna dado de A;
depois com token B retorna dado de B; nenhuma das duas
respostas tem cabeçalhos
Cache-Control: public. CI roda esse
teste em cada PR.
Cache de página inteira em endpoint que mostra
nome do usuário no header. A página
/dashboard é mostly static, mas tem um
"Olá, Camila" no canto. Equipe configura
Cache-Control: public, max-age=300 para
"melhorar performance". Sob carga, dois usuários
diferentes pegam a mesma resposta cacheada — um vê
nome do outro. Defesa: separe a parte personalizada
(carrega via JS) do conteúdo público; ou use
private; ou nunca cacheie HTML que tem
qualquer personalização. Esse é o tipo de incidente
que vira manchete em sistemas com escala (já
aconteceu com Steam, com Reddit, com várias outras
plataformas).
Stale-while-revalidate — UX sem latência de revalidação
stale-while-revalidate (introduzida em RFC
5861, expandida em RFC 9111) é uma das diretivas mais
úteis em latência percebida. A semântica: "se a
resposta cacheada está expirada mas dentro da janela
swr, sirva ela imediatamente e
busque atualização do origin em background".
O efeito: usuário recebe resposta em latência de cache hit (microssegundos), mesmo quando o TTL tecnicamente expirou. O cache se atualiza assincronamente, então a próxima request — ou requests subsequentes do mesmo usuário — verão a versão fresca.
É o padrão moderno para conteúdo dinâmico onde "ligeiramente velho" é aceitável. Listas de produtos, feeds, dashboards. Não apropriado para dado transacional crítico (saldo, status de pedido em curso).
stale-if-error é a contrapartida para
resiliência: serve versão velha quando origin falha.
Combinação típica em sistemas que valorizam
uptime: max-age=60, stale-while-revalidate=86400,
stale-if-error=86400 — TTL de 1 minuto, mas
pode servir até 24h em modo "stale" tanto durante
revalidação quanto durante outage.
HTTP cache vs cache de aplicação
Vale articular como HTTP cache (este conceito) se relaciona com cache aplicacional (módulo 05 conceito 10). São camadas distintas com responsabilidades distintas.
HTTP cache opera no protocolo —
browser, CDN, proxy reverso. Cobre o caminho cliente →
servidor. Granularidade: response inteira por URL +
Vary. Invalidação: TTL ou purge explícito (CDN).
Visível ao desenvolvedor cliente: o JS que
faz fetch respeita o cache HTTP.
Cache aplicacional opera dentro do servidor — Redis, in-memory, HybridCache. Cobre o caminho servidor → banco. Granularidade: por chave arbitrária. Invalidação: por evento, por TTL. Invisível ao cliente.
Os dois se compõem. Request chega ao CDN; CDN serve do seu cache (HTTP cache hit); cliente recebe. Se cache miss, request vai ao origin; origin consulta cache aplicacional para obter dado; serializa em JSON; retorna com cabeçalhos de Cache-Control. CDN armazena. Próxima request do mesmo URL — em qualquer cliente — hit no CDN.
A vitória composta é multiplicativa. Cache HTTP em CDN elimina viagem ao origin para 90% dos requests; cache aplicacional no origin elimina viagem ao banco para 90% dos restantes. Resultado: 99% das requests respondem em < 10 ms; 1% chega ao banco. Sistema que era backed por banco fraco agora aguenta tráfego 100× maior.
Antes de cachear qualquer endpoint via HTTP, responda
três perguntas. "O conteúdo é igual para todos os
usuários, ou depende de identidade?". Se depende,
nunca public — sempre private
ou cache só por usuário com Vary. "Que
cabeçalhos da request afetam o conteúdo?". Liste todos
em Vary. Esquecer um vaza. "Qual é o
máximo de obsolescência aceitável?". Defina
max-age do tamanho da tolerância;
stale-while-revalidate para suavizar
sem prejudicar. Toda configuração de cache HTTP merece
passar por essas três perguntas explicitamente.
Por que importa para a sua carreira
HTTP cache é tema onde a maioria dos engenheiros
pleno-sêniores tem conhecimento superficial. Quem
articula RFC 9111 com precisão se distingue
automaticamente. Em entrevistas para vagas backend,
"como você cachearia uma API REST?" é convite para
mostrar conhecimento de Cache-Control, ETag, Vary; a
resposta forte cita receitas por tipo de recurso e
menciona stale-while-revalidate. Em revisão de código
de novo endpoint, perceber falta de
Cache-Control ou risco de vazamento por
Vary ausente é serviço ao time. Em
pos-mortem de "usuário viu dado de outro usuário",
diagnosticar vazamento por cache vai além de "tem
bug" — vai para "configuração de Vary
incorreta na resposta X". E em discussão de capacity
planning, articular HTTP cache como camada que
reduz tráfego ao origin é parte fundamental.
Como praticar
- Receitas de cache em três tipos de endpoint. Em projeto seu (ou novo) crie três endpoints com configurações de cache distintas: asset estático versionado (immutable, 1 ano), API pública dinâmica (max-age=60, swr=300, ETag), API autenticada (private, ETag). Use ferramenta de teste (curl, Postman, browser DevTools) para verificar: primeiro request retorna 200 com cabeçalhos; segundo retorna 304 quando manda If-None-Match. Esse exercício torna o ciclo concreto.
-
Diagnóstico de vazamento. Crie um
endpoint
/api/meque retorna dados do usuário autenticado. ConfigureCache-Control: public, max-age=60de propósito (anti-padrão). Suba um proxy reverso local (Varnish ou NGINX). Faça request com token A, depois com token B. Verifique que o segundo recebe resposta do primeiro — vazamento. Corrija paraprivate; verifique que o vazamento para. Esse exercício consolida o porquê da regra. -
Audit de Cache-Control em projeto
existente. Pegue um projeto seu e use
ferramenta de inspeção HTTP (browser DevTools,
httpie,curl -I) para examinar cabeçalhos de cache de pelo menos 10 endpoints. Para cada um, classifique: está configurado? Está apropriado para o conteúdo? TemVarycorreto? Identifique pelo menos um endpoint com configuração ausente ou errada e proponha correção. Esse é trabalho que tipicamente ninguém faz e que tem retorno alto.
Referências para aprofundar
- docs RFC 9111 — HTTP Caching (Mark Nottingham, June 2022).
- docs RFC 5861 — HTTP Cache-Control Extensions for Stale Content (Mark Nottingham, May 2010).
- livro HTTP: The Definitive Guide — David Gourley, Brian Totty (O'Reilly, 2002).
- livro High Performance Browser Networking — Ilya Grigorik (O'Reilly, 2013).
- livro Web Performance in Action — Jeremy Wagner (Manning, 2017).
- artigo Caching Tutorial — Mark Nottingham (mnot.net).
- artigo Cache me if you can — Cory House (várias palestras 2017+).
- artigo HTTP Caching Best Practices — Google Web Fundamentals.
- docs Cloudflare Cache.
- docs ASP.NET Core OutputCache.
- docs Varnish HTTP Cache.
- artigo Practical HTTP Cache — Kornel Lesiński (kornel.ski).