Destino Dev

Blog

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

  1. Incluir todos os parâmetros relevantes na chave de cache (query, headers, autenticação).
  2. Definir TTLs razoáveis para o padrão de consistência esperado.
  3. Adicionar limites de memória (LRU) e métricas para hits/misses.
  4. 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