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.
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çãoclients.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.
- 01 diff antes de abrir PR: o hábito que reduz comentários de revisão Ler o próprio diff antes de abrir um PR muda o que você vê no código. Como e por que esse hábito reduz ciclos de revisão e melhora a qualidade dos PRs.
- 02 Cinco expressões regulares que vale a pena memorizar E-mail, slug, telefone BR, hex color e datas ISO — coladas, comentadas e prontas pra usar no seu validator.