Post image

About Neon

Neon is a serverless Postgres platform that helps teams ship faster via instant provisioning, autoscaling, and database branching. We have a generous Free Plan - sign up here.

The Neon Console is a Single Page Application (SPA), or rather, a collection of SPAs. Static assets like JavaScript and CSS are served through CloudFront, while the HTML is dynamically rendered by our Go backend.

In this post, we’ll dive into our journey of migrating from webpack to Vite, the challenges we faced, and what we gained along the way.

Our Webpack Setup

Previously, we used a single Webpack configuration file to build all our apps. Unfortunately, build times were slow, and we lacked hot module replacement support, forcing developers to refresh the page after every change.

Spoiler

After removing Webpack, we eliminated 17 (!) direct dependencies, including Webpack itself and its plugins.

Webpack built all static resources (JS and CSS) and an assets-manifest.json file using the webpack-assets-manifest plugin. When rendering HTML, our Go backend used this manifest file to serve the correct static resources for each entry point. In the local environment, Webpack watched for file changes, rebuilt the assets, and regenerated the manifest file. On every page load, the Go backend read the manifest and returned updated HTML.

In addition to static HTML, our Go backend injects user properties required for the app to render. This eliminates the need for an additional HTTP request when rendering the app in the browser.

  • Build time (with Webpack): 20 seconds
  • Incremental build time: ~1.5 seconds

Vite on The Horizon

We’ve all heard that Vite is the go-to tool for speed and simplicity. While we might have been able to achieve similar results with Webpack, we decided to give Vite a try.

Our migration goals were:

  • Support hot module replacement (HMR)
  • Improve build times
  • Simplify the setup by reducing the number of dependencies
  • Unify tooling around a single core (we were already using Vitest for unit testing and Vite-based Storybook)

We started by migrating our admin application and, once confident with the setup, migrated the rest of the apps in a single change. All users were transitioned to the new Vite-based build in the same deployment—reflecting our confidence that everything would work as expected.

Migration Challenges

Unfortunately, we couldn’t simply use a Vite dev server because HTML generation is handled by the backend. This required us to build a custom proxy using the Vite server.

Fortunately, Vite offers a convenient JS API that is straightforward to use when more control is needed. Additionally, its documentation includes a helpful guide on integrating Vite into existing backend applications.

Here’s how it works locally:

  1. All entry points are listed in vite.config.js to build JS bundles. We don’t use HTML entry points (for most apps).
  2. Production assets are built and included in the backend Docker. On startup, the backend reads the manifest.json file generated by Vite and imports all assets while generating HTML.
  3. Our custom Vite proxy reads the HTML response and replaces production assets with Vite dev imports.
  4. Vite transforms the HTML and injects development scripts.
  5. Developers interact with an HMR-enabled app.
  6. End-to-end (E2E) tests bypass the proxy entirely, allowing us to test production bundles without being slowed down by Vite.
Post image

Excluding comments, our custom proxy is only 150 lines of code and uses the http-proxy dependency—the same one Vite uses internally.

  • Build time (with Vite): 22 seconds

Surprisingly, our build times didn’t improve and even became slightly slower. This caught us off guard, but the HMR functionality and reduction in dependencies were still significant wins.

info

While for most of our apps we specify a JS entrypoint, we do have a single maintenance app that is just static assets. We were positively surprised to learn that Vite works well with both entrypoint types and can build both JS and HTML assets at the same time.

Surprises That We Hit Along the Way

  1. We tried to implement a proxy ourselves using fetch. This worked for basic scenarios but failed in others. Switching to http-proxy made all flows work.
  2. Our backend might return multiple SPA apps in a single HTML. The custom Vite proxy needed to support that as well.
  3. Finding the entry point for a production asset was a challenge. The backend responds with production assets, so the custom Vite proxy needed to locate the assets in the HTML and replace them with a single dev import referencing the correct entry point.
    • We used HTML comments to mark the assets.
  4. We have both frontend and backend developers working with the same dev setup. We wanted our change to be as unnoticeable as possible.
    • This meant running the custom Vite proxy in a container behind Nginx.
  5. Supporting E2E headless tests
    • The Vite dev setup loads every file in a separate request. For our main app, the browser needs to load ~1500 files, which makes cold (empty cache) page loads slow.
    • For E2E tests, we introduced a second way to run our services locally: we skip the proxy altogether. Nginx forwards requests directly to the backend, and production assets are served and tested.
  6. We had to rename one entry point because it conflicted with an API endpoint. The entry point JS file had the same name as the API endpoint, and Vite couldn’t handle it.
  7. The @vitejs/plugin-react-swc extension panicked on syntax errors, forcing developers to restart the proxy. We switched back to @vitejs/plugin-react due to its better stability and error handling. The babel-based plugin was already a huge win for us, especially with HMR.
  8. We encountered Unable to preload CSS for /assets/assets/index-I-pmUzqQ.css errors in production. It seems we are not the only ones experiencing this.
  9. We wanted to use Content Security Policy (CSP) headers in the local environment as well. The custom Vite proxy extends CSP to support Vite’s WebSocket server.
  10. Global process.env.* variables are not defined by default.
  11. No incremental builds
    • Developers who skip the custom Vite proxy won’t see any UI changes until they rebuild the app completely.

Final Outcomes

  • Build times stayed the same.
  • HMR works great
  • The Vite configuration is significantly simpler, with fewer plugins and dependencies
  • A custom Vite proxy had to be implemented
  • Frontend developers are happy 🙂
  • Configuration is now unified across apps, Vitest, and Storybook
  • Vite build artifacts are split into more modules, whereas Webpack produced a single JS and CSS import per app
  • Initial page loads in the local environment take slightly longer, as the browser must fetch thousands of files—but we now refresh much less frequently

Follow-Up Work

Not having faster build times was a huge disappointment for all of us. After analyzing the flamegraphs that Vite produces, we found we were hitting Rollup limits. We are looking forward to the future of Vite powered by Rolldown!

However, we noticed that our Ace Editor dependency could be patched to reduce the number of modules Vite needs to process. Using the yarn patch utility, we modified the library to depend on ~450 fewer modules. This change alone sped up our build times by 10 seconds (~50%)!