Destino Dev

Blog

Implementando Token Bucket no Service Worker para controlar fetchs entre abas

Por John Pires · 08 de março de 2026

Implementando Token Bucket no Service Worker para controlar fetchs entre abas

1) Problema real que resolve

Contexto de uso

Aplicações web modernas frequentemente abrem várias abas conectadas ao mesmo backend. Se cada aba dispara chamadas automáticas — polling, syncs, uploads ou fetchs por chunks — o backend pode ser sobrecarregado ou atingir limites de API. Além disso, o cliente pode ter restrições de banda. Precisamos de um mecanismo simples para limitar o número de requisições efetivas ao servidor, compartilhado entre todas as abas de um usuário no mesmo origin.

Por que o service worker?

O Service Worker (SW) atua como um proxy que intercepta fetchs do cliente e tem escopo por origin. Ele é o lugar ideal para aplicar políticas globais por usuário/cliente: acessível a todas as abas, vive fora do ciclo de vida das páginas e consegue controlar, adiar ou responder fetchs. A solução centralizada evita lógica duplicada em cada aba e facilita monitoramento e persistência.

O mecanismo que vamos implementar é um Token Bucket: um contador de tokens que representa permissões para fazer fetchs. Tokens são preenchidos com uma taxa constante; cada requisição consome tokens. Se não houver tokens, a requisição é enfileirada e liberada quando tokens estiverem disponíveis.

2) Abordagem ingenua vs abordagem otimizada

Abordagem ingênua

Uma forma simples é bloquear client-side: cada aba mantém um contador local e usa localStorage ou BroadcastChannel para sincronizar. Problemas:

  • Condições de corrida ao sincronizar via localStorage.
  • Tabs podem falhar ao encerrar, deixando estado inconsistente.
  • Backoff e enfileiramento são difíceis sem um ponto central.

Abordagem otimizada: Service Worker + Token Bucket

Melhor: implementar o Token Bucket no SW. Vantagens:

  • Estado centralizado: evita conflitos de sincronização entre abas.
  • Persistência: podemos salvar último timestamp no IndexedDB para continuar após restart.
  • Respostas controladas: podemos enfileirar, adiar ou retornar 429 quando apropriado.

Requisitos da solução otimizada:

  1. TokenBucket puidamente implementado (tempo-baseado, não timer-dependente).
  2. Queue de promessas para requests esperando tokens.
  3. Persistência mínima (IndexedDB) para tolerância a reinícios do SW.
  4. Proteções: limite de profundidade da fila, tempo máximo de espera.

3) Implementacao com codigo real

Design do TokenBucket

O algoritmo principal não usa setInterval para refil; ele calcula tokens com base no tempo decorrido desde o último refill. Assim, o SW não depende de timers que morrem quando o SW é inativo. Implementamos:

  • capacity: número máximo de tokens
  • refillRate: tokens por segundo
  • tokens: tokens atuais (pode ser float)
  • lastRefill: timestamp em ms

O método take(n) consome tokens se possível; takeAsync(n) enfileira quando não há tokens. Persistimos lastRefill e tokens no IndexedDB a cada alteração crítica.

Service Worker: código completo (essencial)

O bloco abaixo contém a classe TokenBucket e os handlers principais de fetch. Coloque este arquivo como service-worker.js no seu build. Contexto: intercepta requisições para /api/ e aplica o rate limit compartilhado entre abas.

// service-worker.js
const DB_NAME = 'sw-token-bucket-db';
const STORE = 'bucket';

class TokenBucket {
  constructor({capacity, refillRate}) {
    this.capacity = capacity;
    this.refillRate = refillRate; // tokens per second
    this.tokens = capacity;
    this.lastRefill = Date.now();
    this.queue = []; // [{n, resolve, reject, enqueuedAt}]
    this.maxQueue = 100;
    this.persistLock = false;
  }

  refill() {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    if (elapsed <= 0) return;
    this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
    this.lastRefill = now;
  }

  take(n = 1) {
    this.refill();
    if (this.tokens >= n) {
      this.tokens -= n;
      this.schedulePersist();
      return true;
    }
    return false;
  }

  takeAsync(n = 1, maxWaitMs = 10000) {
    if (this.take(n)) return Promise.resolve(true);
    if (this.queue.length >= this.maxQueue) return Promise.reject(new Error('queue-full'));
    return new Promise((resolve, reject) => {
      const entry = {n, resolve, reject, enqueuedAt: Date.now(), maxWaitMs};
      this.queue.push(entry);
      this.processQueue();
    });
  }

  processQueue() {
    // Attempt to drain queue as tokens become available
    this.refill();
    while (this.queue.length > 0 && this.tokens >= this.queue[0].n) {
      const entry = this.queue.shift();
      this.tokens -= entry.n;
      entry.resolve(true);
    }
    this.schedulePersist();
    // Schedule a wake-up if queue not empty
    if (this.queue.length > 0) {
      const needed = this.queue[0].n - this.tokens;
      const seconds = Math.max(needed / this.refillRate, 0.01);
      setTimeout(() => this.processQueue(), Math.ceil(seconds * 1000));
    }
    // prune expired
    const now = Date.now();
    this.queue = this.queue.filter(entry => {
      if (now - entry.enqueuedAt > entry.maxWaitMs) {
        entry.reject(new Error('timeout'));
        return false;
      }
      return true;
    });
  }

  schedulePersist() {
    if (this.persistLock) return;
    this.persistLock = true;
    // debounce persist
    setTimeout(async () => {
      await saveState({tokens: this.tokens, lastRefill: this.lastRefill});
      this.persistLock = false;
    }, 200);
  }
}

// IndexedDB helpers (minimal)
function openDB() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, 1);
    req.onupgradeneeded = () => {
      req.result.createObjectStore(STORE);
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

async function saveState(state) {
  try {
    const db = await openDB();
    const tx = db.transaction(STORE, 'readwrite');
    tx.objectStore(STORE).put(state.tokens, 'tokens');
    tx.objectStore(STORE).put(state.lastRefill, 'lastRefill');
    return tx.complete;
  } catch (e) { console.warn('persist fail', e); }
}

async function loadState() {
  try {
    const db = await openDB();
    const tx = db.transaction(STORE, 'readonly');
    const tokensReq = tx.objectStore(STORE).get('tokens');
    const lastReq = tx.objectStore(STORE).get('lastRefill');
    return Promise.all([reqToPromise(tokensReq), reqToPromise(lastReq)])
      .then(([tokens, lastRefill]) => ({tokens, lastRefill}));
  } catch (e) { console.warn('load fail', e); return {}; }
}

function reqToPromise(req) {
  return new Promise((res, rej) => {
    req.onsuccess = () => res(req.result);
    req.onerror = () => rej(req.error);
  });
}

// Instantiate bucket and wire fetch
let bucket;
(async () => {
  const config = {capacity: 10, refillRate: 1}; // 10 tokens, 1 token/sec
  const state = await loadState();
  bucket = new TokenBucket(config);
  if (state.tokens != null) bucket.tokens = state.tokens;
  if (state.lastRefill != null) bucket.lastRefill = state.lastRefill;
})();

self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);
  // Apply to API path only
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(handleApiRequest(event.request));
  }
});

async function handleApiRequest(req) {
  try {
    // Try to obtain token, wait up to 10s
    await bucket.takeAsync(1, 10000);
    // Proceed to fetch
    const res = await fetch(req);
    return res;
  } catch (err) {
    if (err.message === 'queue-full') {
      return new Response('Too many queued requests', {status: 429});
    }
    if (err.message === 'timeout') {
      return new Response('Request timed out waiting for rate limit', {status: 429});
    }
    return new Response('Internal rate limiter error', {status: 500});
  }
}

O código acima mostra um SW funcional. Antes de usá-lo em produção, ajuste parâmetros e trate edge cases como requests que não devem consumir token (assets, images) e autenticação.

Registro do Service Worker e wrapper fetch no cliente

No cliente, registre o SW e utilize uma função wrapper que mantém politeness no caso do SW responder 429 ou timeout. Abaixo um exemplo simples que adiciona retry com backoff para 429.

// main.js
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/service-worker.js').then(() => console.log('SW registered'));
}

async function politeFetch(input, init) {
  const maxRetries = 3;
  let attempt = 0;
  while (true) {
    attempt++;
    const res = await fetch(input, init);
    if (res.status !== 429) return res;
    if (attempt > maxRetries) return res;
    // exponential backoff
    await new Promise(r => setTimeout(r, 200 * Math.pow(2, attempt)));
  }
}

// Uso
politeFetch('/api/data').then(r => r.json()).then(console.log);

Contexto: este wrapper tenta mitigar filas cheias retornando 429, fazendo retry do lado do cliente com backoff. Não é obrigatório, mas melhora UX quando o limite é atingido.

4) Testes e validacao

Testes locais com múltiplas abas

Abra várias abas no mesmo origin e execute um script que dispara N requisições por segundo. Observe que o SW limita a taxa total agregada. Use o exemplo abaixo para gerar carga de teste a partir de uma aba.

// stress.js (executar no console de várias abas)
async function stress(ratePerSec, durationMs) {
  const interval = 1000 / ratePerSec;
  const end = Date.now() + durationMs;
  let count = 0;
  while (Date.now() < end) {
    fetch('/api/stress').then(r => r.status).catch(e => console.error(e));
    count++;
    await new Promise(r => setTimeout(r, interval));
  }
  console.log('dispatched', count);
}

// Exemplo: 20 req/s por 10s
stress(20, 10000);

Contexto: execute esse script em várias abas e monitore o backend. Você deverá ver que o número total de requisições que chega ao servidor é limitado pelo token bucket configurado no SW.

Validação de comportamento

Valide os seguintes pontos:

  • O número de respostas 200 vs 429 (ou tempo de espera) condiz com a taxa configurada.
  • Após reiniciar o SW (update/refresh), o bucket restaura estado e não permite bursts maiores que capacity.
  • Fila não cresce indefinidamente — entradas expiradas recebem timeout.

Use devtools do browser e logs no servidor para medir requisições reais. Simule falhas na rede e veja como o SW responde.

5) Consideracoes de producao

Persistência e consistência

Persistimos tokens e lastRefill em IndexedDB para evitar um burst absurdo após restart do SW. Ainda assim, é importante aceitar algum desvio: se o SW perder persistência (e.g., IndexedDB corrompido), pode aceitar algo maior temporariamente. Se isso for crítico, faça checagens no servidor também (defesa em profundidade).

Escalabilidade e limites

Algumas considerações práticas:

  • Tokens fracionários permitem taxa fina. Use floats em ms/segundos.
  • Limite máximo de filas para evitar OOM no SW.
  • Evite enfileirar requests que envolvem body grandes — prefira responder 429 e forçar retry do cliente.
Tip: combine rate-limiter no SW com um controle no servidor (API gateway) para evitar abuso quando o cliente é comprometido.

Segurança e identificação

O SW é por origin. Se precisar distinguir usuários (por token, cookie), a aplicação server-side deve reforçar limites por usuário/tenant. O SW só tem contexto do navegador; não deve confiar em client-side identification para impor limites de segurança.

Monitoramento e métricas

Adicione logs de contagem de tokens usados, duração de fila e eventos 429 para telemetry. Você pode postar eventos para um endpoint interno com amostragem para não inundar o servidor.

Erros comuns e armadilhas

Timer vs tempo-baseado

Muitos tentam manter tokens com setInterval. O SW pode ficar inativo, timers podem ser pausados. Calcule tokens a partir de um timestamp (lastRefill) e taxa — este é o método robusto. Nosso código usa esse método.

Enfileirar todo request

Não enfileire assets estáticos (CSS, JS) ou recursos de alta prioridade. Filtre apenas endpoints que devem ser limitados. Caso contrário, você pode degradar a carregamento da página.

Persistência inconsistente

Não persista a cada operação síncrona. Debounce/write-back periódico (como schedulePersist no código) evita thrash em IndexedDB.

Conclusao pratica

Implementar um Token Bucket no Service Worker é uma solução prática para limitar fetchs entre abas sem multiplicar lógica por página. Use tempo-baseado refill, enfileiramento com limites e persistência mínima para robustez. Combine com políticas do servidor para defesa em profundidade.

Resumo das ações concretas:

  • Coloque a lógica centralizada no SW.
  • Calcule tokens por timestamps, não por timers.
  • Persistir estado crítico no IndexedDB com debounce.
  • Enfileirar com limites e timeout; responda 429 quando necessário.

Este padrão resolve problemas reais de sobrecarga, melhora UX ao distribuir requisições e dá controle fino sobre taxa de saída do cliente. Ajuste capacity/refillRate e políticas de enfileiramento conforme a carga esperada e a sensibilidade da API.

Implementações futuras podem incluir priorização de requests, chaves por usuário e métricas agregadas enviadas a um collector. Mas a base apresentada aqui é um ponto de partida sólido e pragmático para equipes que precisam de controle de tráfego client-side entre abas.

Assuntos: JavaScript, service-worker, token-bucket, rate-limiting, web-api

Leia também