Wie wir das Hauptbundle einer React-App mit 100 Tools von 6 MB auf 307 KB reduziert haben
2026-05-25
Der Vite-Build-Output hat uns das seit Monaten gesagt:
(!) Some chunks are larger than 500 kB after minification.
Wir hatten es ignoriert. Die Seite lief auf Breitband einwandfrei. Die Hintergrund-KI lud eine 30 MB WebAssembly-Datei, wenn sie aufgerufen wurde — ein 6 MB JS-Bundle war doch klein im Vergleich.
War es nicht.
Ein Freund besuchte toolkoala.com auf seinem Pixel über instabiles Hotel-WLAN. Der Largest Contentful Paint dauerte 2,5 Sekunden allein für das Element-Render-Delay — ohne Netzwerkzeit. Das Lighthouse-Audit zog uns weiterhin Punkte ab für „render-blocking resources" und „reduce unused JavaScript". Am schlimmsten: Google Search Console hatte begonnen, die Seite in den Core-Web-Vitals-Berichten zu markieren.
Also setzten wir uns hin und lasen den Build-Output richtig.
dist/assets/index-CdRXzQJL.js 5,998 kB │ gzip: 1,755 kB
Sechs. Megabyte. JavaScript. An jeden Besucher der Startseite ausgeliefert, bevor er irgendwas geklickt hat.
Dieser Beitrag ist das Engineering-Log, wie wir das auf 307 KB raw / 92 KB gzipped runter bekommen haben — 95 % Reduktion — und den LCP von ~2,5 s auf unter 1,5 s. Die Audit-Note ging von F auf A. Die Seite selbst hat sich nicht geändert.
Was in den 6 MB war
Das erste Nützliche war npx vite-bundle-visualizer zu starten (oder einfach den Build-Output nach Größe zu sortieren). Die größten Verursacher:
- Alle 70+ Tool-Komponenten (
RemoveBackground,PdfMerge,LoremIpsum, …) - Alle 8 i18n-Locale-JSON-Dateien (Englisch + 7 Übersetzungen, je ~300 KB)
pdf-lib+pdfjs-dist(zusammen ~500 KB) — von 30 PDF-Tools verwendetUPNG(200 KB) — transitiv eingezogenmarked,diff,jszip,pptxgen,xlsx,mammoth— je 100-500 KB
Das meiste davon gehörte zu Tools, die der Nutzer gar nicht verwendet. Sie wurden heruntergeladen falls der Nutzer nach dem Lorem-Ipsum-Generator den PDF-Merger anklickt.
Das ist das default React-Pattern, und Vite lässt dich das gerne machen:
import RemoveBackground from './tools/RemoveBackground'
import PdfMerge from './tools/PdfMerge'
// … 65 weitere Zeilen
<Routes>
<Route path="/remove-background" element={<RemoveBackground />} />
<Route path="/pdf-merge" element={<PdfMerge />} />
// …
</Routes>
Jede import-Anweisung sagt „Liefere diesen Code auf jeder Seite aus". So werden 70 Tools zu einem 6-MB-Bundle.
Fix 1: Code-Splitting auf Route-Ebene (spart ~5,7 MB)
Der Standard-React-Fix dafür ist React.lazy() plus Suspense. Vites bereits konfigurierter rollup-Bundler emittiert automatisch einen Chunk pro dynamischem Import.
Wir haben jeden Tool-Import konvertiert:
const RemoveBackground = lazy(() => import('./tools/RemoveBackground'))
const PdfMerge = lazy(() => import('./tools/PdfMerge'))
// …
Dann die Routes eingewickelt:
<Suspense fallback={<div className="loading">Loading…</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/remove-background" element={<RemoveBackground />} />
// …
</Routes>
</Suspense>
Home und Nav haben wir statisch importiert gelassen. Die Home ist der häufigste Landing, und die Nav muss sich auf jeder Seite sofort rendern.
Nach dieser einen Änderung: Hauptbundle fiel von 5.998 KB → 3.025 KB. Jedes Tool wurde sein eigener Chunk: je 3-50 KB, on-demand geholt, wenn der Nutzer navigiert.
Der Trade-off: Ein Tool zum ersten Mal zu besuchen erfordert jetzt einen zusätzlichen Netzwerk-Round-Trip, um seinen Chunk zu holen. Aber die Chunks sind klein (oft einstellige KB gzipped), und modernes HTTP/2 amortisiert die Round-Trip-Kosten über mehrere parallele Requests. Bei realen Verbindungen ist die Latenzerhöhung unsichtbar.
Der Gewinn liegt beim ersten Seitenaufruf — dem, der für Largest Contentful Paint und Erstbesucher-Bounce-Rate zählt. Wir hatten ihn halbiert.
Fix 2: Locale-Lazy-Loading mit import.meta.glob (spart ~2 MB)
Der nächste Brocken im Bundle war die Internationalisierung. Wir unterstützen 8 Sprachen, jede mit einer ~300 KB JSON-Datei mit Übersetzungen für Titel, FAQs und Feature-Beschreibungen der 100+ Tools. Die ursprüngliche Konfiguration war das Offensichtliche:
import en from './locales/en.json'
import zhCN from './locales/zh-CN.json'
import zhTW from './locales/zh-TW.json'
import ja from './locales/ja.json'
// … 4 weitere
i18n.init({
resources: {
en: { translation: en },
'zh-CN': { translation: zhCN },
// …
}
})
Alle acht Dateien (~2,5 MB raw, ~700 KB gzip) im Hauptbundle inline. Ein französischer Besucher unserer englischen Home lädt die koreanischen Übersetzungen.
Vite hat ein wunderbares Primitiv namens import.meta.glob, das das in etwa 10 Zeilen behebt:
const localeLoaders = import.meta.glob('./locales/*.json')
const initial = detectLangFromPath() || 'en'
const [enData, activeData] = await Promise.all([
localeLoaders['./locales/en.json'](),
initial !== 'en' ? localeLoaders[`./locales/${initial}.json`]() : null,
])
i18n.init({
resources: {
en: { translation: enData.default },
[initial]: { translation: activeData?.default },
},
lng: initial,
})
Vite sieht jeden import('./locales/<x>.json')-Aufruf und emittiert einen separaten Chunk pro Locale. Beim Start wird nur die aktuelle Sprache des Nutzers (plus Englisch als Fallback) geladen.
Der Fallback ist klein (Englisch wird eagerly geladen, damit fehlende Übersetzungen in anderen Sprachen nicht crashen). Wenn der Nutzer die Sprache manuell wechselt, patchen wir i18n.changeLanguage, um das neue Locale on-demand zu holen:
const origChangeLanguage = i18n.changeLanguage.bind(i18n)
i18n.changeLanguage = async (lng) => {
if (!i18n.hasResourceBundle(lng, 'translation')) {
const data = await loadLocale(lng)
i18n.addResourceBundle(lng, 'translation', data, true, true)
}
return origChangeLanguage(lng)
}
Ein Gotcha: Das macht die i18n-Initialisierung asynchron, also kann React nicht rendern, bevor das Locale aufgelöst ist. Wir haben Vites Unterstützung für top-level await in ESM verwendet:
// main.jsx
import { initI18n } from './i18n'
await initI18n()
createRoot(...).render(<App />)
Top-level await wird in modernen Browsern unterstützt (Chrome 89+, Safari 15+, Firefox 89+).
Nach diesem Fix: Hauptbundle 3.025 KB → 307 KB (gzip 1.755 KB → 92 KB). Wir hatten ~2,7 MB Locale-Daten vom kritischen Pfad entfernt.
Fix 3: Das sekundäre Leck — PdfToolShell (spart ~600 KB)
Nach den ersten beiden Fixes markierte PageSpeed Insights immer noch „Reduce unused JavaScript" mit spezifischen Übeltätern:
pdf-*.js— 116 KB, 96 % ungenutztUPNG-*.js— 118 KB, 28 % ungenutzt
Warum wurden PDF-Bibliotheken auf der Home-Seite geladen?
Wir hatten eine gemeinsame Komponente PdfToolShell, die 30+ PDF-Tool-Routes umhüllt (PDF-Merge, Split, Rotate usw.). Sie ist kein Tool selbst, sondern eine wiederverwendbare Shell. Sie wurde statisch in App.jsx importiert:
import PdfToolShell from './components/PdfToolShell'
Und PdfToolShell macht das oben:
import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib'
import * as pdfjsLib from 'pdfjs-dist'
Also wurde der gesamte PDF-Stack ins Hauptbundle gehoben — selbst wenn der Nutzer /lorem-ipsum besuchte.
Der Fix war die gleiche lazy()-Behandlung:
const PdfToolShell = lazy(() => import('./components/PdfToolShell'))
Die Lektion: Tool-Komponenten lazy zu laden reicht nicht, wenn eine geteilte „Shell"-Komponente weiterhin ihre schweren Dependencies statisch importiert. Auditiere jeden statischen Import dafür, was er transitiv mit reinzieht.
Fix 4: Logo (spart 640 KB)
Das hier ist peinlich. Das Site-Logo war ein 1024×1024 PNG, 640 KB. Es wurde mit 32×32 Pixel in der Navbar angezeigt. Niemand hatte es je in Frage gestellt.
Der Fix dauerte weniger Zeit als diesen Absatz zu lesen: Wir haben logo.png in unseren eigenen Bildkomprimierer gezogen. Der Slider war schon auf 80 % Qualität (Default). Wir klickten Download.
Ergebnis: 6,9 KB. 99 % Reduktion ohne sichtbaren Unterschied bei 32×32 — auch nicht bei 256×256, wo wir dasselbe Logo für die Open-Graph-Social-Card verwenden.
Für den Social-Card-Use-Case (Twitter, Facebook, LinkedIn-Vorschaubilder) wollten wir, dass das Logo bei ~256 px skaliert knackig aussieht. Wir nahmen das original 1024×1024, zogen es in unser Bild-Resize-Tool, setzten 256×256, schickten es durch Bild-Komprimierung mit 90 % Qualität und bekamen logo-256.png mit 64 KB. Das og:image-Meta-Tag zeigt jetzt darauf.
Zwei Tools, zwei Minuten, 633 KB pro Seitenladung gespart.
Wenn du das gerade am Handy liest und das Logo oben gut aussieht: Das ist das 6,9-KB-Bild. Die 640-KB-Version sah identisch aus.
Wenn du auf deiner eigenen Seite ein Logo größer als 50 KB hast, ist der ToolKoala-Bildkomprimierer genau dort. Reinziehen, Before/After-Preview sehen, das Kleinere herunterladen. Nichts wird hochgeladen — dein Logo geht nicht zu unserem Server, es wird in deinem Browser verarbeitet. Das ist der ganze Punkt des Projekts, einschließlich dem Teil, wo wir es auf uns selbst angewendet haben.
Fix 5: Google-Fonts-Abhängigkeit eliminieren (spart ~1,2 s LCP-Delay)
Unser <head> sah aus wie das jeder Web-App, die du je gesehen hast:
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
PageSpeed berichtete dadurch 1.258 ms kritische Pfad-Latenz. Der Browser musste:
fonts.googleapis.comauflösen (DNS, TLS)- Die CSS-Datei herunterladen (ein redirect-lastiges Chaos aus
@font-face-Blöcken) fonts.gstatic.comauflösen (DNS, TLS erneut, anderer Origin!)- Die tatsächliche
.woff2-Datei herunterladen
Für eine Utility-Seite ist Inter schön, aber nicht notwendig. Wir hatten bereits einen perfekt funktionierenden CSS-Fallback:
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
system-ui, Roboto, "Helvetica Neue", Arial, sans-serif;
Auf macOS löst das zu San Francisco auf (Apples Systemschrift). Auf Windows Segoe UI. Auf Android Roboto. Alle drei sind exzellente Schriften, die vorinstalliert im Betriebssystem mitgeliefert werden. Sie verbrauchen null Netzwerk-Bandbreite und null Ladezeit.
Wir haben den Google-Fonts-Link gelöscht. Die Seite sieht zu ~95 % gleich aus — anders auf Linux ohne gute installierte Sans-Serif, aber das ist eine kleine Zielgruppe und Fallbacks degradieren elegant.
PageSpeeds „kritische Pfad-Latenz" fiel um 1,2 Sekunden.
Fix 6: Bundle-CSS inlinen (spart 160 ms)
Die verbleibende render-blocking Ressource war das CSS-Bundle, das Vite emittiert:
<link rel="stylesheet" href="/assets/index-CcF97ram.css">
5,4 KB. Brauchte ~160 ms Round-Trip auf einer typischen Verbindung — blockierte First Paint.
Für ein so kleines CSS-Bundle gewinnt Inlining im HTML gegen Caching. Die Kosten des Inlinings sind, dass jede HTML-Seite jetzt die 5,4 KB mit sich trägt. Der Vorteil ist eine blockierende Anfrage weniger beim ersten Laden.
Unser Build-Skript (das die Vite-Ausgabe für SSR nachbearbeitet) liest jetzt die CSS-Datei und ersetzt sie durch einen <style>-Block:
const cssMatch = baseHtml.match(/<link\s+rel="stylesheet"[^>]*href="([^"]+)"[^>]*>/)
if (cssMatch) {
const cssPath = join(DIST, cssMatch[1].replace(/^\//, ''))
const css = readFileSync(cssPath, 'utf8')
baseHtml = baseHtml.replace(cssMatch[0], `<style>${css}</style>`)
}
Hinweis: Das ist OK für unsere 5-KB-Stylesheet. Wenn du ein 100-KB-CSS-Bundle hast, zerstört Inlining das Caching über Seiten hinweg; lass es extern (oder extrahiere nur das Critical CSS).
Was wir bewusst nicht gefixt haben
Google AdSense und Google Analytics gtag. Beide sind async-Skripte, die PageSpeed weiterhin für Forced Reflow und hohe Transfergröße markiert. Sie sind zusammen 500+ ms kritische Pfad-Latenz.
Aber AdSense ist, wie die Seite finanziert wird. Es zu entfernen ist kein Engineering-Trade-off; es ist ein geschäftlicher. Dasselbe gilt für Analytics — ohne es würden wir blind fliegen darüber, welche Tools beliebt sind und welche Bugs niemand trifft.
Im Prinzip könnten wir AdSense verzögern, um es nach dem Paint des LCP-Elements zu laden. Wir haben das eine halbe Stunde versucht; es erforderte, die Injection-Logik von AdSense selbst zu überschreiben, und produzierte visuelles Flackern, während Werbung post-paint geladen wurde. Nicht wert die Engineering-Komplexität für ~200 ms Einsparung.
Wenn du eine nicht-werbe-finanzierte Seite optimierst, hast du mehr Optionen als wir. Wir mussten das akzeptieren.
Die Ergebnisse
Der Cold-Cache First-Paint für https://www.toolkoala.com/ aus unserem Basic-Benchmark:
| Metrik | Vorher | Nachher | Änderung |
|---|---|---|---|
| Hauptbundle (raw) | 5.998 KB | 307 KB | -95 % |
| Hauptbundle (gzip) | 1.755 KB | 92 KB | -95 % |
| Logo PNG | 640 KB | 7 KB | -99 % |
| Total JS auf Home | ~3 MB | 544 KB | -82 % |
| Render-blocking CSS | 160 ms | 0 ms (inlined) | -100 % |
| Google-Fonts-Blockierung | 1.258 ms | 0 ms | -100 % |
| FCP | ~2,4 s | 256 ms | -89 % |
| LCP / DOM Complete | ~2,5 s | 1.295 ms | -48 % |
| Lighthouse Performance Grade | D | A (6/6 Budget) | — |
Die Seite selbst ist unverändert — gleiche React-Komponenten, gleiche UI, gleiches Backend. Jeder Fix war eine Konfigurations- oder Build-Prozess-Änderung.
Take-aways, wenn du eine React + Vite-App ausrollst
Schau dir deinen Bundle-Output bei jedem Release an. Sind zwei Zeilen in deinem CI-Log. Wenn etwas 500 KB überschreitet, untersuche. Vite sagt es dir kostenlos.
React.lazy()plusSuspenseist der günstigste Performance-Gewinn. Route-Level-Splitting dauert zehn Minuten zu implementieren und spart typischerweise 50-90 % deines Hauptbundles.import.meta.globist Vites Superkraft für Lazy-Loading „vieler Dateien gleicher Art" — Locales, Icons, Blog-Posts, was auch immer. Verwende es statt langer Listen statischer Imports.Auditiere transitive Imports. Eine geteilte „Shell"-Komponente, die statisch schwere Libs importiert, wird all dein Route-Level-Lazy-Loading zunichtemachen. Der PdfToolShell-Bug kostete uns einen 600-KB-Chunk auf jeder Seite, bis wir es bemerkten.
Dein Logo ist wahrscheinlich zu groß. Logos auf den meisten Seiten werden mit ≤64 px angezeigt. Sie sollten ≤10 KB sein. Verwende das Resize-Tool auf genau dieser Seite, wenn du ImageMagick nicht installiert hast.
Hinterfrage jeden externen Font-Load. Systemfonts auf macOS, Windows und Android sind alle exzellent. Wenn deine Marke nicht von einem spezifischen Schriftbild abhängt (und die meisten nicht), lösche den Google-Fonts-Link komplett.
Kleine CSS inlinen. Unter ~10 KB schlägt die gesparte Latenz durch das Wegfallen einer Anfrage den Caching-Vorteil einer externen Datei.
AdSense und Analytics sind weitgehend außerhalb deiner Kontrolle. Akzeptiere es und optimiere, was du kannst.
Das waren insgesamt etwa sechs Stunden Engineering-Arbeit, verteilt auf zwei Tage. Die größten Verbesserungen (Route-Splitting + Locale-Splitting) dauerten je etwa zwei Stunden. Die verbleibenden vier Stunden waren Messung, Regression-Testing und diesen Beitrag schreiben.
Wenn du das nützlich fandest, die Tools, die aus dem saubereren Build hervorgegangen sind, sind immer noch hier, immer noch kostenlos, immer noch komplett im Browser:
- Bildkomprimierung (ja, einschließlich deines Logos)
- PDF-Tools (die, die den Lazy-Loading-Insight ausgelöst haben)
- Videokomprimierung (FFmpeg.wasm, auch lazy)
- OCR (Tesseract.js, auch lazy)
- Der vollständige Blog mit weiteren Engineering-Notes
Alle laden in deutlich unter einer Sekunde auf einer normalen Verbindung. Jetzt.