useServerQuery no Nuxt 3: composable SSR que deduplica, cache e revalida
Por John Pires · 13 de março de 2026

Problema real que resolve
Concorrência no servidor: múltiplas requisições idênticas
Em aplicações Nuxt 3 server-rendered é comum que várias partes da mesma renderização ou vários handlers paralelos precisem dos mesmos dados remotos. Sem controle, cada chamada dispara uma requisição HTTP — sobrecarregando APIs internas/externas, aumentando latência e custo. O problema piora quando a renderização faz fetchs repetidos em middlewares, composables e server routes.
Exemplo prático: duas components server-side chamadas pela mesma rota solicitam o perfil do usuário durante o SSR. Se não houver deduplicação, o backend vai receber 2 chamadas idênticas. Em escala isso se torna ruído e gastos desnecessários.
Cache e revalidação: latência vs frescor
Cache evita chamadas repetidas, mas introduz o trade-off entre latência e frescor dos dados. Estratégias práticas: TTL simples, stale-while-revalidate (SWR) e revalidação em background. Uma boa solução precisa combinar dedupe (para concorrência intra-request), cache com TTL (para latência) e SWR (para manter dados frescos sem bloquear).
O objetivo desta peça é mostrar uma implementação prática de um composable server-only — useServerQuery — que resolve os três pontos: deduplicação, cache e revalidação.
Abordagem ingênua vs abordagem otimizada
Abordagem ingênua: cada chamada faz fetch
Mostre rápido o que acontece no mundo real quando você não otimiza: endpoint server fazendo fetch direto de uma API externa, chamado múltiplas vezes. Isso é simples, mas não escala.
// Contexto: server/api/naive.get.ts
// O que faz: cada invocação faz um $fetch direto ao serviço externo
import { defineEventHandler } from 'h3'
export default defineEventHandler(async () => {
// Chamada direta ao terceiro serviço
const posts = await $fetch('https://jsonplaceholder.typicode.com/posts')
return { posts }
})
O que acontece: se outras partes do SSR também precisarem de 'posts', cada parte dispara seu próprio $fetch. Em picos de tráfego isso vira gargalo e custos.
Abordagem otimizada: dedupe + cache + revalidação
Objetivo: criar um composable que funcione apenas no servidor, mantenha um cache por processo worker, deduplica requisições concorrentes para a mesma chave e suporte stale-while-revalidate. A estratégia adotada usa um Map armazenado em globalThis para manter o cache entre invocações no mesmo worker (Nitro).
Vantagens práticas: baixos números de chamadas para a API externa, respostas rápidas a partir do cache e revalidação em background quando dados estiverem stale.
Implementação com código real
Composable: server/composables/useServerQuery.ts
Contexto: um composable server-only exportado que centraliza cache, dedupe e revalidação. Use em server routes, middleware e server components. Este arquivo fica em server/composables/useServerQuery.ts.
// O que faz: implementa useServerQuery com dedupe, ttl e stale-while-revalidate
// Coloque em server/composables/useServerQuery.ts
type CacheEntry = {
data?: unknown
error?: unknown
expiresAt?: number
promise?: Promise<unknown>
revalidating?: boolean
}
const GLOBAL_KEY = '__nuxt_server_query_cache'
const globalAny: any = globalThis as any
if (!globalAny[GLOBAL_KEY]) globalAny[GLOBAL_KEY] = new Map<string, CacheEntry>()
const cache: Map<string, CacheEntry> = globalAny[GLOBAL_KEY]
export type ServerQueryOptions<T> = {
ttl?: number // tempo em ms para considerar fresh
staleWhileRevalidate?: number // janela para retornar stale + revalidar em background
}
export async function useServerQuery<T>(key: string, fetcher: () => Promise<T>, opts: ServerQueryOptions<T> = {}): Promise<T> {
const now = Date.now()
const ttl = opts.ttl ?? 5_000
const swr = opts.staleWhileRevalidate ?? 0
const entry = cache.get(key)
// 1 - fast path: fresh data
if (entry?.data !== undefined && entry.expiresAt && now < entry.expiresAt) {
return entry.data as T
}
// 2 - if a promise exists, dedupe concurrent callers
if (entry?.promise) {
return entry.promise as Promise<T>
}
// 3 - stale-while-revalidate: return stale immediately and trigger background refresh
if (entry?.data !== undefined && entry.expiresAt && now <= (entry.expiresAt + swr)) {
// background revalidate
entry.revalidating = true
const bg = (async () => {
try {
const fresh = await fetcher()
cache.set(key, { data: fresh, expiresAt: Date.now() + ttl })
return fresh
} catch (err) {
// keep stale on error
return entry.data
} finally {
const e = cache.get(key)
if (e) e.revalidating = false
}
})()
entry.promise = bg
return entry.data as T
}
// 4 - no entry or expired: fetch and cache, storing promise for dedupe
const p = (async () => {
try {
const result = await fetcher()
cache.set(key, { data: result, expiresAt: Date.now() + ttl })
return result
} catch (err) {
// store error object briefly (avoid caching forever)
cache.set(key, { error: err, expiresAt: Date.now() + Math.min(1000, ttl) })
throw err
} finally {
const e = cache.get(key)
if (e) e.promise = undefined
}
})()
cache.set(key, { promise: p })
return p as Promise<T>
}
O que acontece: o composable expõe uma função assíncrona que pode ser usada por server routes e server components. Ele deduplica chamadas concorrentes via a propriedade promise, armazena resultado com expiresAt e aplica SWR quando configurado.
Uso prático em server route
Contexto: um endpoint que agregará dados e usa useServerQuery para evitar múltiplos $fetchs ao mesmo recurso. Coloque em server/api/posts.get.ts.
// O que faz: retorna posts reusando cache/dedupe do useServerQuery
import { defineEventHandler } from 'h3'
import { useServerQuery } from '../composables/useServerQuery'
export default defineEventHandler(async () => {
const fetchPosts = () => $fetch('https://jsonplaceholder.typicode.com/posts')
// Repare: chave simples. Em produção inclua params de query, headers, etc.
const posts = await useServerQuery('posts:all', fetchPosts, { ttl: 10_000, staleWhileRevalidate: 30_000 })
return { posts }
})
O que acontece: o primeiro request peerock busca a lista e cacheia por 10s; chamadas seguintes dentro de 10s retornam instantaneamente do cache. Após 10s e antes de 40s (ttl + swr), retornamos stale e revalidamos em background.
Testes e validação
Teste manual: simulando concorrência em uma server route
Contexto: você quer provar que dedupe funciona. Crie uma server route que executa 10 chamadas ao mesmo fetcher em paralelo e conta quantas vezes a função fetch real foi chamada.
// O que faz: simula 10 chamadas paralelas para o mesmo recurso e reporta contagem de fetchs reais
import { defineEventHandler } from 'h3'
import { useServerQuery } from '../composables/useServerQuery'
let realFetchCount = 0
const slowFetcher = async () => {
realFetchCount++
// simula delay e retorno
await new Promise(r => setTimeout(r, 100))
return { ts: Date.now() }
}
export default defineEventHandler(async () => {
const promises = Array.from({ length: 10 }, () => useServerQuery('s:clock', slowFetcher, { ttl: 5000 }))
const results = await Promise.all(promises)
return { realFetchCount, resultsLength: results.length }
})
O que acontece: com dedupe, realFetchCount deve ser 1. Sem dedupe cada chamada incrementaria realFetchCount para 10.
Teste com vitest (unitário) — mockando o fetcher
Contexto: mostra um teste simples usando vitest para garantir comportamento determinístico. Este exemplo assume que o composable foi exportado para uso em testes.
// O que faz: unit test para confirmar dedupe e cache
// Arquivo: tests/useServerQuery.spec.ts
import { describe, it, expect } from 'vitest'
import { useServerQuery } from '../server/composables/useServerQuery'
it('deduplica chamadas concorrentes e aplica cache', async () => {
let calls = 0
const fetcher = async () => {
calls++
await new Promise(r => setTimeout(r, 10))
return { v: calls }
}
const p1 = useServerQuery('ut:1', fetcher, { ttl: 1000 })
const p2 = useServerQuery('ut:1', fetcher, { ttl: 1000 })
const [r1, r2] = await Promise.all([p1, p2])
expect(r1).toEqual(r2)
expect(calls).toBe(1)
// chamada subsequente usa cache
const r3 = await useServerQuery('ut:1', fetcher, { ttl: 1000 })
expect(calls).toBe(1)
})
O que acontece: o teste garante que múltiplos consumidores partilham a mesma promise e que o fetcher só executa uma vez enquanto o cache estiver fresh.
Considerações de produção
Armadilhas comuns e limites
Nem tudo é magia: o map em globalThis é por-worke r. Em ambientes com múltiplos processos (cluster), cada worker terá seu próprio cache — não há compartilhamento entre processos. Para caches compartilhados use Redis ou Memcached e armazene metadados (promise dedupe não funciona cross-process sem trabalho adicional).
Se precisar de deduplication cross-process, armazene um estado de lock no Redis e implemente lógica de espera/notify; cuidado com deadlocks e latência adicional.
Erros comuns:
- Não incluir os parâmetros na chave de cache (query, headers, user id) — isso causa dados errados sendo servidos.
- TTL muito alto para dados voláteis; SWR muito curto que causa revalidações frequentes desnecessárias.
- Esquecer de limpar entradas com erro — cache temporário de erro é aceitável, mas não indefinido.
Observabilidade, métricas e limites de memória
Instrumente: métricas para hits/misses, revalidations, número de promises ativas. Adicione limites para o cache (LRU ou cap máximo de entradas) para evitar crescimento ilimitado do Map em long-running workers.
Exemplo simples de monitoração: aumente um contador sempre que o fetcher executa e exponha uma rota /internal/metrics que lê esses contadores para Prometheus. Sem monitoramento, você não saberá se o cache está efetivamente evitando chamadas.
Conclusão prática
Resumo direto
useServerQuery, implementado como composable server-only, resolve três problemas práticos: deduplicação de requisições concorrentes, redução de chamadas externas via cache e boa experiência de frescor via stale-while-revalidate. A implementação mostrada é compatível com Nuxt 3 (server/composables) e é adequada para cenários onde cache por-processo é aceitável.
Checklist antes de adotar
- Incluir todos os parâmetros relevantes na chave de cache (query, headers, autenticação).
- Definir TTLs razoáveis para o padrão de consistência esperado.
- Adicionar limites de memória (LRU) e métricas para hits/misses.
- Para multi-processo em produção, considerar cache centralizado e estratégia de dedupe cross-process se necessário.
Implementar useServerQuery é um ganho de performance e custo quase imediato em aplicações SSR. Comece com uma versão simples do composable, meça, depois evolua para LRU e integração com Redis se a topologia exigir. O código acima é um ponto de partida direto e testável para integrar dedupe, cache e revalidação no Nuxt 3.
Dica final: mantenha o fetcher puro (sem efeitos laterais) e o key determinístico. Isso simplifica caching e evita surpresas na produção.
Assuntos: Nuxt.js, ssr, composables, server-routes
Leia também
Virtualização reativa em Vue 3: Composition API + Pinia para listas gigantes
Prático e direto: como substituir o anti-pattern de renderizar tudo por uma virtualização reativa em Vue 3 usando Composition API e Pinia, com padrões…
Autenticação SSR em Nuxt 3 com refresh tokens rotativos e server routes
Guia prático para implementar autenticação SSR em Nuxt 3 usando refresh tokens rotativos, cookies httpOnly e server routes. Padrões, code samples e armadilhas…
Autocomplete reativo em Vue 3 com debounce, cache local e Pinia
Tutorial prático para iniciantes: construa um autocomplete reativo em Vue 3 usando Composition API, debounce e cache local com Pinia. Mostro o jeito errado e…
Streaming SSR em Nuxt 3: server routes Nitro com SSE e NDJSON e consumo no cliente
Como implementar streaming eficiente em Nuxt 3 usando Nitro: compare o anti-padrão de acumular tudo com as rotas SSE/NDJSON corretas, composables cliente…
Implementando ISR em Nuxt 3 (Nitro): gerar, invalidar e cache por rota
Padrão prático de ISR em Nuxt 3 (Nitro): cache por rota de dados, stale-while-revalidate, revalidação em background e endpoint seguro de invalidação. Código…
Nuxt: criar um módulo que auto-importa composables e server routes com cache SSR
Módulo Nuxt que automatiza auto-import de composables e registro de server routes, adicionando um wrapper de cache SSR configurável. Reduz boilerplate…