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:
- TokenBucket puidamente implementado (tempo-baseado, não timer-dependente).
- Queue de promessas para requests esperando tokens.
- Persistência mínima (IndexedDB) para tolerância a reinícios do SW.
- 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
Web Components semânticos para formulários: labels, validação e acessibilidade
Padrões práticos para construir Web Components de formulário mantendo labels, validação e acessibilidade: quando usar Shadow DOM, ElementInternals e fallbacks…
Web Components semânticos: Shadow DOM acessível e indexável para SEO
Padrões práticos para criar Web Components semânticos: mantenha semântica no light DOM, use Shadow DOM só para estilo, ou implemente ElementInternals com…
WebAssembly com Rust para o navegador: compilar e usar com JS (iniciantes)
Guia prático para iniciantes: compile Rust para WebAssembly e consuma no navegador via JS. Passo a passo para tokenizacao, cálculo de scores e integração com…
Retry resiliente com fetch: AbortController, backoff exponencial e streaming
Padrões práticos para retries resilientes com fetch: combine AbortController para cancelamento, backoff exponencial com jitter para evitar picos, e streaming…
Criando um simulador interativo do Event Loop: Call Stack, Tasks e Microtasks
Aprenda, do zero, como call stack, microtasks e tasks interagem no event loop. Construa um simulador interativo que demonstra a ordem de execução, evite…
Pool de concorrência com Async Iterators e AbortSignal
Implementar um pool de concorrência com Async Iterators e AbortSignal para controlar tarefas assíncronas, permitir cancelamento cooperativo e aplicar…