Library
Learning Patterns · 2 of 12
Learning Patterns
UI/UX Design HIGH

Code Splitting and Loading Strategies

Learning Patterns Lydia Hallie & Addy Osmani
code-splitting dynamic-import lazy-loading facade-pattern route-splitting

Key Principle

Shift JavaScript cost from initial page load to moment-of-need. Static imports force every module into the critical bundle regardless of whether the user ever triggers it. Code splitting decomposes that monolithic bundle into conditional chunks loaded by route, viewport entry, or user interaction -- converting a serial upfront cost into a pay-per-use model.

Why This Matters

Bundle size directly gates Time To Interactive. The browser cannot paint until the engine reaches the first rendering call, so every unnecessary byte before that point is dead wait time. A 1.5 MiB all-static bundle that could be split into a 1.33 MiB main bundle plus on-demand chunks delays interactivity for code the user may never touch. On low-end devices and slow connections, network transfer -- not parsing -- is the real bottleneck, making smaller initial bundles the highest-leverage optimization.

Good Examples

Dynamic import with React.lazy + Suspense: React.lazy(() => import('./EmojiPicker')) signals Webpack to extract EmojiPicker into a separate chunk with zero manual config. Wrapping in <Suspense fallback={<Spinner />}> converts the loading gap into a progress signal rather than a perceived freeze.

Route-based splitting: Users already expect loading delays during navigation, making route transitions the natural lazy-load boundary. Each route loads only its own bundle via React.lazy() + <Suspense> + router, shrinking the initial bundle to entry-route code only.

Import on interaction (facades): Replace eagerly-loaded third-party widgets with lightweight HTML+CSS replicas; load real JS only on user action. High-value case studies:

Case Study JS Deferred Result
Postmark help widget +314 KB TTI: 7.7s to 3.7s
Calibre (Intercom chat) +245 KB 30% performance improvement
Google Sign-In facade +180 KB Main thread freed on initial load
YouTube Lite Embed ~540 KB Non-interacting users pay zero cost

Import on visibility: IntersectionObserver detects when a placeholder enters the viewport, then triggers import(). Below-the-fold components contribute zero value at initial load; deferring them directly improves FCP, LCP, and TTI. Libraries like react-loadable-visibility abstract the observer wiring.

Google Hotels component-level splitting: Clicking "Filters" fetches only +30 KB JS+Data. Component-granularity aligns download cost with actual usage, outperforming route-level splitting which bundles features a user may never touch.

Counterpoints

  • SSR gap: React Suspense does not support server-side rendering. Use @loadable/component for SSR apps that need lazy-loading.
  • Early-click paradox: Interaction-driven loading can lose clicks that arrive before the framework bootstraps. Google solves this with JSAction -- a tiny event library shipped in initial HTML that captures and replays early clicks.
  • Facades lose autoplay: Deferred embeds visible in the initial viewport cannot autoplay or function without user action. When functionality must be available immediately, lazy-load on scroll-into-view instead.
  • Network latency on slow connections: Direct click-to-load degrades UX. Mitigate with idle-time prefetch, hover-triggered prefetch, or eagerness scaled to browser signals (network speed, Data Saver mode).
  • Static embeds for above-the-fold: Embeds in the initial viewport cannot use facades. Build-time static HTML replicas eliminate multi-MB JS payloads entirely (e.g., 2.5 MB embed reduced to static HTML).

Key Quotes

  • "By dynamically importing the EmojiPicker component, we managed to reduce the initial bundle size from 1.5MiB to 1.33MiB! ...we have improved the user experience by making sure the application is rendered and interactive while the user waits for the component to load." (Dynamic Import Pattern)
  • "This change reduced Time to Interactive from 7.7s to 3.7s." (Import on Interaction, Chat Widgets / Postmark)
  • "A bigger bundle doesn't necessarily mean a longer execution time. It could happen that we loaded a ton of code that the user won't even use!" (Bundle Splitting, p. 3)
  • "Only very minimal code is downloaded initially and beyond this, user interaction dictates which code is sent down when." (Import on Interaction, attributed to Shubhie Panicker, p. 5)
  • "The developer is still in charge of optimizing two steps in the process: the loading time and execution time of the requested data." (Bundle Splitting)

Rules of Thumb

  1. Static import = initial bundle. Every import X from 'module' lands in the critical path. Audit static imports for anything gated behind interaction, feature flags, or below-the-fold positioning.
  2. Route splits are the safe default. Users expect navigation delays; route-based splitting is low-risk, high-reward.
  3. Third-party embeds are the highest-value facade targets. They load synchronously, block parsing, delay hydration, and may never be used.
  4. Never place synchronous third-party scripts in <head>. Load non-blocking 3P scripts after first-party JS finishes.
  5. Preconnect on hover, load on click. For interaction-deferred resources, preconnecting to required origins on hover reduces the latency penalty at click time.
  6. Use the loading priority spectrum: Eager (critical) > Prefetch (anticipated) > Lazy-Route (navigation) > Lazy-Viewport (scroll) > Lazy-Interaction (click/hover).

Related References

  • ScriptLoader priorities: before-interactive (polyfills, GDPR), after-interactive (analytics), lazy-onload (social widgets)
  • SSR hydration uncanny valley: page looks ready but is not interactive, causing rage clicks
  • webpackChunkName magic comments for named chunk output
  • react-lazyload, react-loadable-visibility for viewport-triggered loading