Todos os artigos
74 artigos · atualizado semanalmente Veja nossas Ferramentas
Todos os artigos
Tutoriais

TBT de 1.6s em produção: o que de fato resolveu no Quick Tools com SvelteKit

Caso real de otimização de performance em SvelteKit: manualChunks, dynamic imports, service worker e o bug de clients.claim() que quebra chunks após deploy.

COVER · Tutoriais

O Lighthouse rodou e devolveu 59/100 em Performance, TBT entre 1.064ms e 1.648ms, bundle principal de 1.43MB. Tínhamos mais de 70 ferramentas no ar, cada uma com suas dependências específicas — highlight.js, marked, ajv, qr-code-styling, js-yaml — todas no bundle principal, baixadas e parseadas em toda visita, independente de qual ferramenta o usuário queria usar.

Esse post documenta as oito fases de otimização que fizemos: o que resolveu, o que surpreendeu, e um bug de service worker que perdemos várias horas debugando e que a maioria dos artigos de performance não menciona.


O diagnóstico: por que o bundle estava tão grande

O problema não era uma coisa só. Era a soma de vários erros de categoria.

O Vite faz code splitting automático, mas para dependências de terceiros ele tende a criar um chunk vendor único com tudo junto. Resultado: um usuário que acessa o conversor de texto baixava highlight.js. Quem usava a calculadora baixava js-yaml. Ninguém precisava de nada daquilo, mas o browser precisava parsear antes de qualquer interação — daí o TBT alto.

O segundo problema era que mesmo com import() dinâmico no código, algumas importações críticas ainda estavam estáticas em arquivos de rota. Importação estática significa que o browser precisa do módulo antes de renderizar a página — sem negociação.


manualChunks + dynamic imports: a fundação

A primeira fase foi configurar manualChunks no vite.config.ts para separar cada biblioteca pesada no seu próprio arquivo:

manualChunks(id) {
  if (!id.includes('node_modules')) return;
  if (id.includes('/highlight.js/'))    return 'vendor-highlight';
  if (id.includes('/marked/'))          return 'vendor-markdown';
  if (id.includes('/ajv/'))             return 'vendor-ajv';
  if (id.includes('/jsonrepair/'))      return 'vendor-jsonrepair';
  if (id.includes('/qr-code-styling/')) return 'vendor-qr';
  if (id.includes('/js-yaml/'))         return 'vendor-yaml';
  if (id.includes('/fuse.js/'))         return 'vendor-search';
  if (id.includes('/lucide-svelte/') || id.includes('/@iconify/')) return 'vendor-icons';
  if (id.includes('/bits-ui/') || id.includes('/svelte-sonner/')) return 'vendor-ui';
}

Só isso já muda o comportamento: um usuário que acessa o JSON validator carrega vendor-ajv e vendor-jsonrepair. Quem usa o gerador de QR code carrega vendor-qr. Os outros chunks não são nem requisitados.

O segundo benefício é cache invalidation mais cirúrgico. Quando a lógica do JSON validator muda, apenas vendor-ajv e o chunk da ferramenta são invalidados. O vendor-highlight, o vendor-yaml e o resto continuam em cache — o usuário não baixa nada que não mudou.

A segunda parte foi converter imports estáticos de libs pesadas para dynamic import nos arquivos de rota:

// antes — força download antes da renderização
import { marked } from 'marked';

// depois — só carrega quando a tool de fato precisa
const { marked } = await import('marked');

A nuance do SSG que a maioria ignora

Tem um detalhe importante para quem usa SvelteKit com adapter-static que não está documentado de forma óbvia: o load em +page.ts só executa no build time.

No cliente, o SvelteKit usa o __data.json pré-renderizado — o load nunca re-executa no browser. Isso significa que dynamic imports dentro do load function não têm custo no bundle client-side. O output já está serializado no JSON; os chunks de markdown, YAML ou o que for nunca são fetched no cliente.

A implicação prática: com SSG, você pode fazer await import('lib-pesada') dentro do load sem culpa. O custo de parse dessa lib nunca aparece no TBT do usuário final.


Prefetch e service worker com cache-first

Com o code splitting no lugar, a navegação entre ferramentas ficou mais leve — mas ainda havia latência perceptível no primeiro acesso a cada ferramenta, porque o chunk precisava ser baixado.

A solução foi adicionar data-sveltekit-preload-code="viewport" no <body>:

<body data-sveltekit-preload-code="viewport">

Com isso, quando um link de ferramenta entra no viewport — seja em desktop ou mobile — o SvelteKit já começa a buscar o JS chunk daquela ferramenta em background. Quando o usuário clica, o chunk provavelmente já está em cache. A navegação parece instantânea.

O service worker complementa com cache-first para assets com hash:

// Cache-first para assets com hash (chunks do build — nunca mudam)
if (PRECACHE.includes(url.pathname)) {
  const cached = await cache.match(event.request);
  if (cached) return cached;
}
// Network-first para HTML — pode atualizar a cada deploy

Um arquivo vendor-highlight-abc123.js tem o hash no nome e nunca muda. Se está em cache, serve do cache direto — zero rede. Nas visitas seguintes, praticamente todo o JS e CSS já está no cache do browser.


O bug que a maioria não conta: clients.claim() e chunk 404 após deploy

Após a primeira rodada de otimizações, o TBT caiu para 610ms. Mas começaram a aparecer erros no console de usuários em produção:

GET https://quickeasy.tools/_app/immutable/chunks/BOiooyxC.js 404
TypeError: Failed to fetch dynamically imported module: .../DWxm8pPm.js

Usuários com HTML em cache de um deploy anterior tentavam carregar chunks com hashes antigos — arquivos que não existiam mais após o novo deploy. As ferramentas falhavam silenciosamente ao abrir.

A causa raiz era uma combinação de dois comportamentos do service worker que, juntos, formam uma armadilha:

  • skipWaiting() ativa o novo SW imediatamente após a instalação
  • clients.claim() faz o novo SW assumir o controle de todas as abas abertas — incluindo abas que ainda têm HTML antigo com referências a hashes de chunks antigos

O novo SW não tem cache dos chunks antigos. Todo dynamic import nessas abas resulta em 404.

A correção foi remover clients.claim() do handler activate:

self.addEventListener('activate', (event) => {
  event.waitUntil(evictOldCaches());
  // Sem clients.claim() — abas abertas continuam no SW antigo até navegar
});

Sem clients.claim(), abas abertas continuam usando o SW anterior (que ainda tem os chunks antigos em cache) até o usuário navegar. Abas novas pegam o novo SW imediatamente. Sem 404s.

Como rede de segurança para casos extremos — uma aba aberta exatamente durante a janela do deploy, ou HTML cacheado direto no browser sem passar pelo SW — adicionamos um hooks.client.ts:

export const handleError: HandleClientError = ({ error }) => {
  const msg = error instanceof Error ? error.message : String(error);

  const isChunkLoadError =
    msg.includes('Failed to fetch dynamically imported module') ||
    msg.includes('Importing a module script failed');

  if (isChunkLoadError) {
    const key = 'qt-chunk-reload';
    if (typeof sessionStorage !== 'undefined' && !sessionStorage.getItem(key)) {
      sessionStorage.setItem(key, '1');
      window.location.reload();
    }
  }

  return { message: msg || 'Unexpected error' };
};

O guard com sessionStorage evita loop infinito caso a rede em si esteja quebrada.


Tirando do critical path o que não precisa estar lá

Mesmo com o code splitting funcionando, havia dois componentes que rodavam trabalho pesado em todo carregamento de página.

Search index. O componente de layout fazia fetch do JSON de busca e construía um índice Fuse.js com 71 ferramentas em cada mount — mesmo para usuários que nunca tocariam na caixa de busca. A correção foi tornar o SearchBox autocontido e lazy:

async function ensureIndex() {
  if (indexLoaded || indexLoading) return;
  indexLoading = true;
  const [{ default: Fuse }, res] = await Promise.all([
    import('fuse.js'),
    fetch(`/search-index-${lang}.json`)
  ]);
  fuse = new Fuse(await res.json(), { keys: [...], threshold: 0.35 });
  indexLoaded = true;
}

async function handleFocus() {
  await ensureIndex(); // só dispara no primeiro foco ou Ctrl+K
}

O Fuse.js e o JSON de busca saíram completamente do critical path. Agora só carregam quando o usuário de fato abre a busca.

ThemeCustomizer. O drawer de personalização de tema era importado estaticamente no +layout.svelte, adicionando peso ao chunk do layout — baixado em todas as páginas. 99% dos usuários nunca abrem esse drawer. A solução foi importar dinamicamente, atado ao store de abertura:

let ThemeCustomizer: Component | null = $state(null);

$effect(() => {
  if ($customizerOpen && !ThemeCustomizer) {
    import('./ThemeCustomizer.svelte').then(m => {
      ThemeCustomizer = m.default;
    });
  }
});

O componente não existe no bundle inicial. É carregado apenas na primeira vez que o usuário abre o customizer.


CSS que bloqueia sem parecer que bloqueia

Dois problemas de CSS contribuíam para o TBT sem aparecer óbvios no profiler.

O primeiro era transition: all 0.2s ease em alguns componentes. transition-all inclui propriedades como height, width e top — todas elas trigam layout reflow no main thread. A correção foi simples:

/* antes — passa pelo layout thread */
transition: all 0.2s ease;

/* depois — só compositing, vai direto para a GPU */
transition: opacity 0.2s ease, transform 0.2s ease;

O segundo foi content-visibility: auto no grid de ferramentas. A homepage renderizava todos os 71 cards antes de completar o primeiro paint — incluindo os 50+ fora da viewport:

.tool-card-item {
  content-visibility: auto;
  contain-intrinsic-size: 0 180px;
}

content-visibility: auto diz ao browser que pode pular layout e paint de itens fora da viewport. contain-intrinsic-size fornece um placeholder de tamanho estável para a scrollbar não pular durante o scroll. O browser passou a fazer layout de ~20 cards, não de 71.


O que de fato moveu a agulha

Depois das duas rodadas, essas foram as técnicas com impacto real no TBT e no Speed Index:

Técnica O que resolveu
manualChunks + dynamic imports Eliminou libs pesadas do bundle inicial
Viewport prefetching Navegação sem latência percebida no mobile
Service worker cache-first Latência próxima de zero em visitas recorrentes
Remoção do clients.claim() Corrigiu chunk 404s silenciosos após deploys
hooks.client.ts como fallback Rede de segurança para edge cases de deploy
Search index lazy + Fuse.js on-demand Removeu parse de 71 ferramentas do carregamento inicial
ThemeCustomizer com import dinâmico Retirou componente pesado do chunk do layout
Animações composited-only Eliminou layout reflow nas transições
content-visibility: auto Browser pula render de 50+ cards fora da viewport

O padrão que surgiu ao longo de todo o processo: adiar tudo que não é necessário para a view atual. O primeiro paint deve custar o mínimo possível; todo o resto pode esperar até o usuário realmente precisar.

Em SvelteKit com adapter-static e muitas features, performance não tem bala de prata. São camadas independentes — bundle, cache, CSS, service worker — e cada uma pode ser o gargalo. O lugar certo para começar é o Lighthouse com throttling de CPU ativado, não o feeling de "parece rápido" em localhost.


Nota: o conteúdo editorial acabou aqui. O que vem abaixo é uma indicação do projeto descrito no post.


Sobre o Quick Tools

O Quick Tools é a plataforma descrita neste post — mais de 70 ferramentas para devs, finanças e produtividade, rodando como site estático com SvelteKit. Acesse em quickeasy.tools.

RD
Autor
Rafael Duarte
Desenvolvedor backend com passagem por fintech e SaaS B2B — trabalhou em times que escalaram APIs de zero a milhões de requisições. Carrega cicatrizes de produção suficientes para ter opiniões fortes sobre ferramentas, padrões e decisões de arquitetura. Não é acadêmico: leu a RFC do UUID quando precisou escolher entre v4 e v7 para uma tabela de alta escrita.
Ver perfil