Lazy-Loading Frontend Components in a Rails Inertia.js Architecture
When integrating a modern frontend framework like React, Vue, or Svelte with Ruby on Rails via Inertia.js, we frequently face a significant architectural challenge. The default build configuration often bundles all frontend pages and components into a single, monolithic JavaScript file.
As an application grows in complexity, this bundle size increases linearly. The browser must download, parse, and execute the entire JavaScript payload before the initial view becomes interactive. This severely degrades critical performance metrics, particularly Largest Contentful Paint (LCP) and Time to Interactive (TTI), for users on constrained networks or less powerful devices.
To mitigate this performance penalty, we must implement code splitting and lazy loading. This approach ensures that the client only downloads the JavaScript necessary for the current page, deferring the rest until the user navigates.
Understanding Code Splitting with Inertia.js
Code splitting is a mechanism that divides our application code into smaller, independent chunks. When combined with Inertia.js, we can instruct our bundler — typically Vite or Webpack in modern Rails applications — to generate a separate JavaScript file for each page component.
A standard, eager-loaded Inertia configuration imports all components at build time. When a user requests any page, the server responds with an HTML document that references the entire bundle.
In contrast, a lazy-loaded configuration utilizes dynamic imports. When the server instructs the client to render a specific page component, the client fetches only the corresponding chunk over the network. This dramatically reduces the initial payload and accelerates the rendering process.
Implementing Dynamic Page Imports with Vite
In a Rails environment utilizing vite_ruby, we manage our entrypoints in the frontend directory. To enable code splitting for Inertia page components, we must modify the resolve function within our Inertia setup.
An eager-loaded configuration in Vite typically relies on import.meta.glob with the eager flag enabled:
// frontend/entrypoints/application.js
import { createInertiaApp } from '@inertiajs/svelte'
createInertiaApp({
resolve: name => {
const pages = import.meta.glob('./Pages/**/*.svelte', { eager: true })
return pages[`./Pages/${name}.svelte`]
},
setup({ el, App, props }) {
new App({ target: el, props })
},
})
To implement lazy loading, we remove the eager: true option. This changes the behavior of import.meta.glob to return a function that yields a Promise, rather than the resolved module itself:
// frontend/entrypoints/application.js
import { createInertiaApp } from '@inertiajs/svelte'
createInertiaApp({
resolve: name => {
const pages = import.meta.glob('./Pages/**/*.svelte')
return pages[`./Pages/${name}.svelte`]()
},
setup({ el, App, props }) {
new App({ target: el, props })
},
})
Notice that the lazy-loaded implementation executes the function and returns a Promise (return pages[...]()). Inertia is designed to wait for this Promise to resolve before rendering the page, allowing Vite to fetch the required chunk asynchronously.
Lazy Loading Nested Components
Splitting code at the page level solves the initial payload problem, but complex views often contain heavy, interactive elements that are not immediately visible or necessary upon initial render. Examples include rich text editors, complex data visualization libraries, or modal dialogs.
For these specific use cases, we should implement component-level lazy loading. The implementation varies depending on the chosen frontend framework.
In a Svelte application, we can dynamically import a heavy component within the onMount lifecycle hook or utilize an {#await} block:
<!-- frontend/Pages/Dashboard.svelte -->
<script>
import { onMount } from 'svelte'
let HeavyChartComponent
export let chartData
onMount(async () => {
HeavyChartComponent = (await import('../Components/HeavyChart.svelte')).default
})
</script>
<h1>Analytics Dashboard</h1>
{#if HeavyChartComponent}
<svelte:component this={HeavyChartComponent} data={chartData} />
{:else}
<div class="skeleton-loader">Loading chart...</div>
{/if}
In a React application, we would use React.lazy combined with a Suspense boundary:
// frontend/Pages/Dashboard.jsx
import React, { Suspense } from 'react'
const HeavyChartComponent = React.lazy(() => import('../Components/HeavyChart'))
export default function Dashboard({ chartData }) {
return (
<div>
<h1>Analytics Dashboard</h1>
<Suspense fallback={<div className="skeleton-loader">Loading chart...</div>}>
<HeavyChartComponent data={chartData} />
</Suspense>
</div>
)
}
This targeted approach removes heavy dependencies from the initial page rendering cycle, further optimizing performance metrics.
Handling Network Latency and Fallbacks
While lazy loading improves initial load times, it introduces a new architectural consideration: network latency during navigation. Because the browser must download a new JavaScript chunk before rendering a new page or a nested component, users may perceive a delay.
We must provide immediate feedback to mitigate this user experience trade-off.
Inertia provides a built-in progress bar package (@inertiajs/progress) that intercepts navigation events. It displays a loading indicator at the top of the viewport when a dynamic import requires more than a specified threshold — typically 250 milliseconds.
import { InertiaProgress } from '@inertiajs/progress'
InertiaProgress.init({
delay: 250,
color: '#29d',
includeCSS: true,
showSpinner: false,
})
For nested component lazy loading, we should render fallback states — such as skeleton screens or localized loading spinners — while the component chunk resolves, as demonstrated in the previous examples.
Measuring the Impact on Technical Health
Before and after implementing these architectural changes, we should establish baseline metrics using tools like Lighthouse or WebPageTest. The primary indicators of success will be a reduction in JavaScript execution time and an improved LCP.
Transitioning to a lazy-loaded architecture within a Rails and Inertia application requires careful configuration, but it prevents the frontend bundle from becoming an unmanageable bottleneck. By splitting code at both the page and component levels, we ensure the application remains scalable and performant as we continue to modernize legacy views.
Sponsored by Durable Programming
Need help maintaining or upgrading your Ruby on Rails application? Durable Programming specializes in keeping Rails apps secure, performant, and up-to-date.
Hire Durable Programming