Zaktualizowano: 2 czerwca 2026
Optymalizacja bundle'a JavaScript w 2026 to proces zmniejszania ilości kodu JS wysyłanego do przeglądarki przy użyciu trzech głównych technik: tree shakingu (usuwanie martwego kodu z modułów ESM), code splittingu (dzielenie bundle'a na mniejsze chunki ładowane na żądanie) oraz import maps (natywne mapowanie modułów bez bundlera). Razem potrafią zredukować rozmiar transferu nawet o 60–80%, poprawić metrykę INP o 100–300 ms i przyspieszyć LCP na wolniejszych urządzeniach mobilnych. W tym przewodniku pokażę dokładnie, jak je wdrożyć w 2026.
Czym jest optymalizacja bundle'a JavaScript?
Mówiąc krótko, optymalizacja bundle'a JavaScript to zbiór technik mających na celu zmniejszenie ilości kodu JS, który przeglądarka musi pobrać, sparsować, skompilować i wykonać przed wyrenderowaniem interaktywnej strony. W praktyce chodzi o cztery wymiary: rozmiar transferu (bajty po kompresji), rozmiar parsowania (bajty po dekompresji), liczbę żądań HTTP oraz priorytety ładowania poszczególnych chunków. W 2026 te wymiary mają bezpośredni wpływ na Core Web Vitals: zbyt duży bundle wydłuża LCP (przez render-blocking JS), psuje INP (przez długie zadania w głównym wątku) i pogarsza TTFB w SSR.
Szczerze mówiąc, wbrew temu co sugeruje wiele starszych poradników, sama minifikacja przez Terser albo SWC daje tylko 30–40% redukcji. Prawdziwy zysk pojawia się dopiero, gdy łączymy tree shaking, code splitting i agresywną kompresję Brotli. W jednym z moich ostatnich projektów (React 19 z biblioteką UI Mantine) zeszliśmy z 850 KB transferu do około 180 KB. Bez usuwania funkcjonalności. W kolejnych sekcjach pokażę każdą technikę z gotowym kodem produkcyjnym, który możesz wdrożyć w swoim projekcie jeszcze dziś.
Tree shaking w 2026: jak działa eliminacja martwego kodu?
Tree shaking to statyczna analiza grafu importów ESM, podczas której bundler oznacza eksporty nigdy nieużywane jako "martwe" i usuwa je z finalnego bundle'a. Termin pochodzi z Rollupa (2015) i opiera się na deterministycznej naturze modułów ECMAScript. W przeciwieństwie do CommonJS, gdzie require() może być wykonane warunkowo, statement import jest statycznie analizowalny już na etapie parsowania. Dlatego pierwszą i najważniejszą decyzją w 2026 jest konsekwentne używanie ESM w całym repozytorium, włącznie z zależnościami z node_modules.
Drugim warunkiem skutecznego tree shakingu jest prawidłowo zdefiniowane pole sideEffects w package.json. Bez niego bundler przyjmuje pesymistycznie, że każdy moduł może mieć efekt uboczny (np. modyfikację globala) i nie usuwa go, nawet jeśli żaden eksport nie jest używany. Oto poprawna konfiguracja dla biblioteki publikowanej na npm w 2026:
{
"name": "@example/ui-kit",
"version": "3.0.0",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./styles.css": "./dist/styles.css"
},
"sideEffects": [
"*.css",
"./dist/polyfills.js"
]
}Tablica sideEffects wymienia wzorce plików, które MAJĄ efekty uboczne. Wszystkie pozostałe są bezpieczne do usunięcia. Pliki CSS zawsze trzeba zachować (importują style, które są efektem ubocznym). Według oficjalnej dokumentacji Webpacka błędne ustawienie sideEffects: false potrafi wyłączyć ważne polyfille i jest najczęstszym źródłem regresji w produkcji.
Sprawdzenie skuteczności tree shakingu
Do weryfikacji polecam rollup-plugin-visualizer albo vite-bundle-visualizer. Pokazują dokładnie, które moduły zajmują miejsce po tree shakingu:
// vite.config.ts
import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
build: {
rollupOptions: {
plugins: [
visualizer({
filename: 'dist/stats.html',
template: 'treemap',
gzipSize: true,
brotliSize: true,
}),
],
},
},
});Po buildzie otwórz dist/stats.html. Jeżeli widzisz tam pełną bibliotekę typu lodash zamiast pojedynczych funkcji, masz problem: importujesz przez import _ from 'lodash' zamiast import debounce from 'lodash/debounce' (lub jeszcze lepiej lodash-es, które wspiera tree shaking natywnie).
Code splitting: dynamic imports i lazy loading komponentów
Code splitting dzieli bundle na mniejsze chunki ładowane dopiero, gdy są potrzebne. W 2026 standardem jest dynamiczny import() ze specyfikacji ECMAScript, a wszystkie współczesne bundlery (Vite, Webpack, Rolldown, esbuild) traktują każdy import() jako punkt podziału. Najczęstsze wzorce to splitting per route (każda strona ma swój chunk) oraz splitting per komponent (modale, edytory, wykresy ładują się dopiero po interakcji).
Oto produkcyjny przykład dla React 19 z lazy loadem ciężkiego komponentu edytora:
import { lazy, Suspense, useState } from 'react';
// Edytor ~280 KB, ładujemy tylko gdy user kliknie "Edytuj"
const RichTextEditor = lazy(() =>
import('./RichTextEditor').then((m) => ({ default: m.RichTextEditor }))
);
export function ArticleView({ article }) {
const [editing, setEditing] = useState(false);
return (
<article>
<h1>{article.title}</h1>
{!editing ? (
<>
<div dangerouslySetInnerHTML={{ __html: article.html }} />
<button onClick={() => setEditing(true)}>Edytuj</button>
</>
) : (
<Suspense fallback={<p>Ładowanie edytora…</p>}>
<RichTextEditor initialContent={article.html} />
</Suspense>
)}
</article>
);
}W ten sposób użytkownik, który tylko czyta artykuł (a to jakieś 90% ruchu), nigdy nie pobiera kodu edytora. Przekłada się to bezpośrednio na lepszy INP, bo główny wątek nie musi parsować 280 KB nieużywanego JS. Jeśli zależy ci na maksymalnym INP, warto zajrzeć do osobnego przewodnika o debugowaniu INP z Long Animation Frames API, gdzie pokazuję dokładne profilowanie długich zadań.
Preload i prefetch dla code-split chunków
Code splitting bez priorytetyzacji bywa pułapką, bo opóźnia kluczowe zasoby. Rozwiązanie to <link rel="modulepreload"> dla chunków potrzebnych natychmiast oraz Speculation Rules API dla nawigacji:
<!-- Modulepreload pobiera ESM równolegle z głównym dokumentem -->
<link rel="modulepreload" href="/assets/chunk-react-vendor.js" />
<link rel="modulepreload" href="/assets/chunk-router.js" />
<!-- Prerender następnej strony przy hover (Speculation Rules) -->
<script type="speculationrules">
{
"prerender": [
{ "where": { "href_matches": "/article/*" }, "eagerness": "moderate" }
]
}
</script>Import maps: natywna alternatywa dla bundlera
Import maps to standard W3C, który pozwala przeglądarce mapować specyfikatory modułów (np. react) na konkretne URL-e, bez potrzeby bundlera czy bare specifier transformacji. W 2026 są wspierane przez 96% globalnego ruchu (Chrome 89+, Firefox 108+, Safari 16.4+, Edge 89+) i stają się realną opcją dla małych aplikacji oraz hybryd SSR z island architecture.
Przykład prostej aplikacji bez bundlera, która wciąż używa React 19 z CDN:
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="utf-8" />
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@19.1.0",
"react-dom/client": "https://esm.sh/react-dom@19.1.0/client",
"@/": "/src/"
}
}
</script>
</head>
<body>
<div id="app"></div>
<script type="module">
import { createRoot } from 'react-dom/client';
import { App } from '@/App.js';
createRoot(document.getElementById('app')).render(<App />);
</script>
</body>
</html>Import maps + bundler hybrydowy
Ciekawe podejście to scenariusz, w którym bundler tworzy chunki, ale wspólne dependencje (np. React) ładujemy z CDN przez import map. Daje to lepszy cache między domenami i pozwala wyciągnąć ciężkie biblioteki z głównego bundle'a. Vite 6 i SvelteKit oferują tę opcję natywnie przez plugin vite-plugin-importmaps.
Webpack vs Vite vs Rollup vs esbuild, porównanie
Wybór bundlera w 2026 powinien być świadomy, bo różnice w czasie buildu i jakości tree shakingu są znaczące. Poniższa tabela podsumowuje najważniejsze wymiary po stabilizacji Rolldowna w Vite 6 (kwiecień 2026).
| Cecha | Webpack 5 | Vite 6 (Rolldown) | Rollup 4 | esbuild 0.25 |
|---|---|---|---|---|
| Czas buildu (medium SPA) | 45–80 s | 3–6 s | 15–25 s | 0.5–2 s |
| HMR (dev) | średni | natychmiastowy | brak natywnego | natychmiastowy |
| Jakość tree shakingu | dobra | doskonała | doskonała | dobra |
| Code splitting | zaawansowany | zaawansowany | zaawansowany | podstawowy |
| Ekosystem pluginów | największy | duży (kompatybilny z Rollup) | duży | średni |
| Wsparcie CSS/asset | pełne | pełne | przez pluginy | podstawowe |
| Rekomendacja 2026 | legacy | domyślny wybór | biblioteki | narzędzia CLI |
Krótkie podsumowanie. Dla nowej aplikacji w 2026 zacznij od Vite 6, bo łączy szybkość esbuilda w dev mode z jakością Rolldowna w produkcji. Webpack 5 ma sens tylko dla utrzymania starszych projektów albo specyficznych integracji (Module Federation w wersji 1.5+). Rollup wciąż króluje przy publikowaniu bibliotek na npm dzięki najczystszemu outputowi ESM. esbuild zostaw do narzędzi CLI, devserverów i serverless bundlowania, gdzie liczy się każda milisekunda.
Jak zmierzyć rozmiar bundle'a JavaScript?
Pomiar musi obejmować trzy poziomy: rozmiar transferu (brotli), rozmiar po dekompresji (parse cost) oraz koszt egzekucji w głównym wątku. Najszybszy sposób w 2026 to zestaw Lighthouse + Bundlewatch + WebPageTest. W CI pipeline polecam bundlewatch, który blokuje merge'y przekraczające zdefiniowany budżet:
// bundlewatch.config.json
{
"files": [
{
"path": "dist/assets/index-*.js",
"maxSize": "170 KB",
"compression": "brotli"
},
{
"path": "dist/assets/chunk-vendor-*.js",
"maxSize": "90 KB",
"compression": "brotli"
}
],
"ci": {
"trackBranches": ["main", "production"]
}
}Dla głębszej analizy użyj zakładki Coverage w Chrome DevTools. Pokazuje ona, ile procent pobranego JS nigdy się nie wykonało. Jeśli widzisz tam wartości powyżej 60% dla głównego bundle'a, masz wyraźny sygnał do agresywniejszego code splittingu. Warto połączyć to z analizą krytycznej ścieżki renderowania, opisałem ją dokładnie w przewodniku o optymalizacji krytycznej ścieżki renderowania.
Kompresja Brotli i bundle budgets
Brotli przy poziomie 11 (max) jest dziś standardem dla statycznych zasobów. Według web.dev oraz HTTP Archive 2026, mediana redukcji względem gzip dla JavaScriptu wynosi 18–22%. Cloudflare, Fastly, Vercel i Netlify domyślnie serwują Brotli; własny Nginx wymaga modułu ngx_brotli:
# nginx.conf, Brotli dla statycznych assetów
brotli_static on;
brotli on;
brotli_comp_level 6;
brotli_types
application/javascript
application/json
text/css
text/html
text/plain
image/svg+xml;
# Brotli precompresji (poziom 11) z build pipeline
location ~* \.(js|css)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}Bundle budget to twardy limit, którego nie wolno przekroczyć. Rekomendacje na 2026 dla pierwszego paint paylodu (HTML + krytyczny JS + CSS): poniżej 170 KB skompresowane dla mid-tier Androida (75. percentyl), poniżej 100 KB, jeśli celujesz w rynki z wolniejszą siecią. Przekroczenie tych wartości praktycznie zawsze powoduje regresję LCP powyżej 2.5 s na urządzeniach 4G. Sam się o tym przekonałem, gdy w jednym projekcie e-commerce dorzucenie 40 KB do głównego chunka cofnęło LCP o pełne 600 ms na średnim Pixelu.
Najczęstsze błędy obniżające skuteczność tree shakingu
Po przeglądzie kilkudziesięciu projektów klientów w ostatnim roku zebrałem listę błędów, które systematycznie psują tree shaking nawet w dobrze skonfigurowanym buildzie:
- Import całej biblioteki zamiast pojedynczych funkcji.
import * as utils from 'lodash'wciąga 70 KB;import debounce from 'lodash/debounce'wciąga 2 KB. - Mieszanie CJS i ESM w monorepo. Jeden pakiet w CommonJS zatruwa cały graf, bo bundler nie wie, czy moduł ma side effects.
- Barrel files z efektami ubocznymi.
index.tsreeksportujący wszystko z folderu wymusza importowanie całego folderu. Rozwiązanie: ESMexportswpackage.jsonz subpath exports. - Polyfille importowane bezwarunkowo. Zamiast
import 'core-js/stable'w głównym entry, użyj@babel/preset-envzuseBuiltIns: 'usage', który dorzuca polyfille tylko dla używanych API. - Brak
"type": "module"wpackage.json. Bez tego Node i bundlery traktują pliki.jsjako CommonJS i tracą tree shaking. - Skrypty zewnętrzne ładowane synchronicznie. Tagi Google, Hotjar i podobne blokują główny wątek, przenieś je do Web Workerów (Partytown) jak opisałem w artykule o optymalizacji skryptów zewnętrznych.
Najczęściej zadawane pytania
Jak zmniejszyć rozmiar bundle'a JavaScript w 2026?
Połącz trzy techniki: tree shaking (poprzez ESM i poprawne sideEffects), code splitting (dynamiczny import() dla rzadko używanych komponentów) oraz kompresję Brotli na poziomie 11. W typowym projekcie React daje to redukcję rzędu 60–80%. Ustaw bundle budget 170 KB skompresowane dla pierwszego paint paylodu i pilnuj go w CI przez bundlewatch.
Czym różni się code splitting od lazy loading?
Code splitting to technika buildu, w której bundler rozdziela kod na osobne pliki. Lazy loading to wzorzec runtime: dopiero gdy komponent jest potrzebny, przeglądarka pobiera odpowiedni chunk. Code splitting bez lazy loadingu nie da zysku; lazy loading bez splittingu jest niemożliwy. W praktyce dynamiczny import() realizuje obie role jednocześnie.
Czy import maps zastąpią bundlery?
Nie w przewidywalnej przyszłości. Import maps świetnie radzą sobie z mapowaniem specyfikatorów, ale nie oferują tree shakingu, transpilacji TypeScript/JSX ani inteligentnego code splittingu. Najlepiej sprawdzają się w małych aplikacjach, micro-frontendach oraz scenariuszach, gdzie chcesz załadować wspólne dependencje (np. React) z CDN poza głównym bundle'em.
Jaki jest optymalny rozmiar bundle'a JS dla strony mobilnej?
Dla 75. percentyla urządzeń mobilnych (mid-tier Android, 4G) trzymaj pierwszy paint payload poniżej 170 KB skompresowanego Brotli. Po dekompresji to około 500 KB JS do sparsowania, co na Snapdragon 7 Gen 1 zajmuje około 250 ms. Powyżej tych wartości LCP regularnie przekracza próg 2.5 s, a INP wpada w zakres "needs improvement".
Czy esbuild jest lepszy niż Webpack?
Do dev serwera i prostych narzędzi CLI zdecydowanie tak, esbuild jest 50–100× szybszy. Do złożonych aplikacji produkcyjnych Vite 6 (oparty na Rolldown) jest obecnie lepszym wyborem, bo łączy szybkość esbuilda w dev mode z dojrzałością Rollupa w produkcji oraz pełnym wsparciem ekosystemu pluginów. Webpack 5 ma sens głównie dla legacy projektów i Module Federation.
Dlaczego tree shaking nie działa w moim projekcie?
Najczęstsze przyczyny w kolejności występowania: (1) importujesz bibliotekę w CommonJS zamiast ESM, (2) package.json nie ma flagi sideEffects lub jest ona błędnie ustawiona, (3) używasz barrel files (index.ts reeksportujących wszystko), (4) import jest dynamiczny ze zmienną w środku (import(\`./\${name}\`)). Uruchom vite-bundle-visualizer, by zobaczyć, które moduły są niepotrzebnie wciągane.




