How I Optimized the Performance of My Vite + React Website

When I started building Moon Dasha, a SaaS platform powered by Vedic Astrology, I chose Vite, React, and TypeScript because I wanted a fast development workflow without giving up the flexibility required for a large web application.
Vite kept development quick, and the first version of the website loaded well. But as the application grew to include more calculators, reports, result tabs, blog posts, and images, its production performance required a lot more attention.
A fast build tool does not automatically make the website fast for users. The application architecture, JavaScript bundle, images, API requests, third-party embeds, and hosting still determine what visitors experience.
In this article, I will walk you through how I optimized my Vite + React website and the decisions that made the biggest difference.
The initial load improved most when I stopped loading every route and result tab upfront. AVIF images, cached API responses, lighter YouTube embeds, on-demand blog content, and Cloudflare Pages handled the remaining bottlenecks.
What Is Moon Dasha and Why Did It Need Performance Optimization?
Moon Dasha is a powerful Vedic Astrology platform that provides calculators, birth chart reports, panchang information, live planetary positions, vimshottari dasha reports, love compatibility checker, and detailed predictions.
It is not a small landing page with a few static sections. A user may enter birth details, calculate a chart, open several result tabs, compare planetary positions, read a long report, or move between different astrology tools. The website also contains an expanding blog and several image-heavy informational pages.
The application had grown well beyond what I could sensibly include in the initial JavaScript bundle. It has a lot of useful code, but most visitors do not need all of it at once.
For example, someone opening a Panchang page does not need to download the JavaScript for every birth chart calculator. A visitor reading a blog post does not need the code for all eight sections of an astrology report. Even inside a report, someone viewing the basic chart details may never open the Career or Remedies tab.
Performance is especially important for Moon Dasha because it receives high traffic and primarily serves an Indian audience interested in Vedic Astrology. A large share of these visitors use mobile phones, including low and medium-end devices, over cellular connections.
On a powerful desktop with a fast connection, an oversized JavaScript bundle can appear acceptable. On a slower mobile CPU, the browser still has to download, parse, compile, and execute that code. Large images and repeated API requests add even more waiting time.
For Moon Dasha, the performance score was secondary. I wanted the calculators to respond quickly, users to reach their reports sooner, and the complete application to remain usable on the devices and networks the audience actually uses.
Why I Chose Vite for the React Website
I wanted Moon Dasha to be a client-side React application with reusable components, typed data, and a straightforward deployment process. React was a natural fit for the interactive calculators and result views, while TypeScript made it easier to manage the growing number of data structures and component properties.
I chose Vite for three main reasons:
- Fast development server: Vite serves source files on demand using native ES modules during development, so it does not need to bundle the entire application before starting.
- Quick updates: Hot Module Replacement updates the changed module without refreshing the complete page.
- Optimized production builds: Vite handles bundling, minification, asset processing, and code splitting for production.
Performance was one of the main reasons for choosing Vite. The fast development experience made it easier to build and test a large number of pages. More importantly, its production build system gave me the tools required to split the application into smaller chunks.
However, Vite cannot decide the ideal loading strategy for every application. If I statically import 89 route components, Vite has to include them in the dependency graph of the initial application. I still had to show it where the useful boundaries were.
So I started with the route imports.
Reducing the Initial JavaScript Bundle
The original App.tsx file imported route components statically. This was simple, but it also meant that code for calculators, dashboards, blog pages, and reference pages could become part of the initial loading path.
I converted these route components to dynamic imports using React.lazy():
import { lazy, Suspense } from 'react';
const BirthChartPage = lazy(() => import('./pages/BirthChartPage'));
const PanchangPage = lazy(() => import('./pages/PanchangPage'));
function AppRoutes() {
return (
<Suspense fallback={<RouteLoader />}>
{/* Application routes */}
</Suspense>
);
}
There are now 89 lazy route declarations in the application. According to the React documentation, a lazy component's code is not loaded until it is rendered for the first time. This allows Vite to create separate chunks for different routes.
As a result, a user visiting one calculator does not have to immediately download the code for every other calculator and report page.
A single Suspense boundary provides a route-loading fallback. This keeps the implementation manageable and gives the user immediate feedback if a route chunk takes a little longer to arrive.
The rule I followed was simple: if the current page does not need a component, it should not be part of the initial download.
Loading large amounts of unused JavaScript is one of the most common web performance mistakes, especially as a React application grows over time.
Splitting the Heavy Result Sections
Route-level splitting solved only part of the problem.
The results page is one route, but it contains several large sections:
- Basics
- Planets
- Charts
- Dashas
- Predictions
- Life
- Career
- Relationships
- Remedies
If I placed all of these sections in one route chunk, opening a report would still load a lot of code before the user could see the basic results.
I kept the lightweight Basics section immediately available and added independent dynamic loaders for the other eight sections. A tab is instantiated after the user visits it, so the initial report can remain much smaller.
The loader caches both the pending import promise and the resolved module. This prevents repeated tab visits from starting duplicate imports. If an import fails, its pending cache is cleared so the normal loading process can try again.
Some result sections also contain local state that users expect to keep while switching between tabs. Those sections remain mounted after the first visit rather than being recreated every time.
The first report load stayed lighter, while tabs that the user had already opened remained quick and kept their state.
Prefetching Routes Based on User Intent
Code splitting introduces a tradeoff. It reduces the initial bundle, but the browser may have to wait for a new chunk after the user clicks a link.
To reduce this delay, I added intent-based route prefetching.
When a user keeps the pointer over an internal link for 100 milliseconds, the application starts preparing that route. Very short accidental hovers are ignored. Prefetching also starts when a link receives keyboard focus or the user begins a touch interaction.
Instead of attaching separate listeners to every link, the implementation uses event delegation at the document level. It only considers same-origin routes, and all prefetch requests are cached and deduplicated.
I also prepare important destination routes before form submissions. When a user submits birth details, the application already knows that the next page will be a results page, so it can begin fetching the required route chunk before navigation completes.
The same idea is applied inside the results page. On a normal connection, the application starts preparing the remaining result tabs shortly after the basic result appears. This makes later tab changes feel much faster.
However, prefetching is skipped when the browser reports Data Saver, slow-2g, or 2g. Downloading every possible tab would defeat the purpose of optimization for a user trying to conserve data on a slow connection.
Prefetch failures never block navigation. The regular lazy import can still retry when the user opens the route.
Converting Images to AVIF During the Vite Build
Moon Dasha contains a large number of JPG and PNG images. At the time of the audit, the repository had 126 eligible JPG, JPEG, and PNG source files.
Manually maintaining an AVIF copy of every image would be easy to forget, so I added a custom Vite plugin that runs after the production build is complete. It recursively scans the output directory and uses Sharp to perform real AVIF encoding.
The current settings are:
| Setting | Value |
|---|---|
| AVIF quality | 52 |
| Encoding effort | 4 |
| Concurrent conversions | 4 |
The plugin normalizes image orientation, processes both files imported from src and files copied from public, and ignores SVG files. Each generated file keeps the same base name as the original:
hero-astrology.jpg
hero-astrology.avif
The original JPG or PNG remains in the production build as a fallback.
One useful debugging lesson here is that changing a filename extension does not convert an image. I verify the generated file's MIME type, byte size, and ability to decode. I also record the total bytes of the original raster files and the generated AVIF files during the build so I can compare actual output rather than assuming compression happened.
AVIF is not automatically the best choice for every type of image, but it works well for much of the photographic and illustrated content on Moon Dasha. Our WebP vs AVIF comparison explains the compression, decoding, and browser support differences in more detail.
Serving AVIF with a Safe Fallback
I still needed a safe way for the browser to select AVIF when supported and fall back to the original file when required.
I created a reusable OptimizedImage component that renders eligible local raster images using the <picture> element:
<picture>
<source srcset="image.avif" type="image/avif">
<img src="image.jpg" alt="Description of the image" width="800" height="600">
</picture>
The component passes dimensions, alternate text, CSS classes, lazy-loading settings, and event handlers to the fallback <img> element.
It does not rewrite external images because the application cannot guarantee that a matching AVIF file exists on another origin. SVG and other non-raster sources also remain regular image elements.
There was one development issue to handle. AVIF files are generated only after vite build, so they do not exist during npm run dev. The component therefore adds the AVIF <source> only when import.meta.env.PROD is true. Development uses the original image, while production and npm run preview use the complete fallback flow.
I also had to cover images that do not originate from ordinary JSX:
- Blog index cards use
OptimizedImage. - Individual blog covers use
OptimizedImage. - Images inside Markdown use a custom React Markdown
imgrenderer.
Replacing JSX image elements alone would have missed the images generated by the Markdown renderer.
Finally, I added native loading="lazy" to selected below-the-fold images such as blog cards, planet cards, tool cards, and icons inside long result lists. Important above-the-fold images are not lazy loaded because that can delay LCP. If you are optimizing a hero image, our guide to preloading the LCP image covers the opposite case where an image needs higher priority.
Loading Blog Content Only When It Is Opened
The Moon Dasha blog contains full Markdown and MDX articles. Loading every article body just to display the blog index would waste JavaScript and bandwidth.
I created a lightweight generated metadata file for the listing page. The blog index only needs information such as the title, description, date, and cover image.
The full content is handled with import.meta.glob(), which creates a lazy loader for every Markdown and MDX article. The selected article is downloaded only when its route is opened.
TanStack Query then caches the loaded article indefinitely for that browser session. Bundled article content cannot change until a new deployment, so repeatedly refetching it would provide no benefit.
Blog posts can also contain YouTube videos. A normal YouTube iframe loads a substantial amount of third-party code even when the visitor never plays the video. I replaced it with lite-youtube-embed, which initially shows a lightweight placeholder. Its JavaScript is dynamically imported only when a page containing the component is mounted.
Reducing Repeated API Requests
JavaScript was not the only source of unnecessary work. Astrology calculators depend on APIs, and repeated requests can make the application feel slow while adding avoidable load to the backend.
I used TanStack Query to set caching rules based on how frequently each type of data can change.
For example:
- Location autocomplete results remain fresh for five minutes.
- Moon phase, Panchang, and Hora requests use five-minute stale periods where appropriate.
- Stable ephemeris and phase datasets use an infinite stale time.
- Several queries do not refetch when the browser window regains focus.
- Queries wait until every required input is available.
- Calculators that should run only after submission use
enabled: falseand an explicit refetch.
There is no single cache duration that works for every API. A location suggestion can be reused for a few minutes, while a stable bundled article or fixed dataset can remain cached for the complete session.
I also changed the location search itself. Input is debounced by 500 milliseconds, autocomplete does not start until the query contains more than three characters, and results are cached by the search term for five minutes.
Up to ten recently selected locations are also stored locally. A returning visitor can select a previous location without making another search request.
Improving Loading States and Perceived Performance
Not every optimization reduces transfer size or execution time. Some changes improve how loading feels.
A spinner that appears for a fraction of a second can make a fast operation feel unstable. I delay loading indicators by 150 milliseconds. If the operation completes quickly, the spinner never appears. Once shown, it remains visible long enough to avoid a distracting flash.
Route and result-section fallbacks also provide feedback while a chunk or API response is loading.
These loading states do not make the network faster, so I treat them as perceived performance improvements rather than speed gains. They make the interface feel more consistent while the actual work happens in the background.
Using Cloudflare Pages for Fast Global Delivery
The production build is deployed on Cloudflare Pages. The static files are delivered through Cloudflare's global edge network, while SPA routes receive the built index.html file.
The Vite application already produces a static dist directory, which maps cleanly to Cloudflare Pages. Static assets can be served close to the visitor instead of travelling to one centralized origin for every request.
For an audience distributed throughout India and other regions, reducing network distance helps keep server response time low. It also lets the application begin downloading its route chunks, fonts, styles, and images sooner.
Hosting is not a replacement for client-side optimization. A fast edge response cannot compensate for a huge JavaScript bundle or oversized images. Once the frontend is optimized, Cloudflare Pages serves that output from the edge. I have covered its performance and developer experience in more detail in the Cloudflare Pages vs Vercel comparison.
I also added preconnect hints for Google Fonts and its font asset domain so the browser can begin DNS, TCP, and TLS setup earlier. Analytics and monitoring scripts use async or defer to avoid blocking HTML parsing.
SpeedVitals RUM is configured for SPA monitoring as well. SPA monitoring matters here because a client-side React application can feel fast on the first page and slow during later navigations. Real-user monitoring makes it possible to observe Core Web Vitals across those route changes rather than relying only on the initial load.
What Made the Biggest Difference?
Most of the gains came from removing resources from the initial loading path instead of trying to make every resource load a little faster.
The same approach worked throughout the application:
- Routes load only when they are needed.
- Heavy result sections load only when they are opened or intelligently prefetched.
- Prefetching follows user intent and connection quality.
- Blog articles load separately from their metadata.
- YouTube code loads only on pages that contain a video.
- APIs run only when their required inputs are ready.
- Images below the fold wait, while important images keep their priority.
Compression, minification, and a fast CDN still matter. But avoiding unnecessary work is usually more effective than trying to make unnecessary work slightly faster.
Conclusion
There was no single setting in vite.config.ts that fixed Moon Dasha's performance.
Vite gave Moon Dasha a fast foundation and an excellent development experience, but the application still needed deliberate loading boundaries. Route-level code splitting reduced the initial JavaScript. Feature-level splitting kept the results page manageable. Intent-based prefetching made navigation responsive without ignoring slow connections. AVIF conversion reduced image delivery costs while the original files provided a safe fallback.
The remaining gains came from loading blog content on demand, replacing heavy YouTube embeds, caching API responses, debouncing search, and using Cloudflare Pages.
Moon Dasha kept all of its React features, but visitors no longer have to download and execute every one of them during the first page load.
If you are optimizing your own Vite + React website, start with the loading path. Find what the first screen truly needs, split everything else at sensible boundaries, and then use real user data to decide what to improve next.
