How Faabul uses deferred loading, versioned directories, and background checks to conquer web caching issues.
At Faabul, we build a platform for building and hosting live quizzes. Flutter Web is crucial for us, allowing audience members to join live games instantly via a browser link, without requiring an app installation.
To optimize initial load times, especially on mobile, we extensively leverage Flutter’s deferred loading. This splits the application code into a main bundle (main.dart.js) and numerous smaller chunks (*.part.js) loaded on demand.
However, this introduces a significant challenge with browser caching. While caching is generally good, aggressive caching of JavaScript assets can lead to users running outdated code after a new deployment. This problem is amplified by deferred loading. If the browser serves a new main.dart.js but uses cached, older *.part.js files from a previous version, the application often crashes at runtime with a DeferredLoadException due to API mismatches between the main bundle and its parts.
In fact, the DeferredLoadException is the best outcome in this case, as most often this mismatch simply results in some weird unhandled JavaScript error and a blank page for the user.
Our goals:
- Ensure users can continue to use the version already opened in their browsers until they choose to refresh the browser tab to update.
- Notify users actively if a new version becomes available, prompting them to refresh.
- Ensure users always load a consistent, complete, and up-to-date version of the application on page load or refresh.
When you run `flutter build web` with deferred loading enabled, the output (`build/web/`) contains:
- index.html: The main entry point.
- flutter.js, flutter_bootstrap.js: Flutter’s web engine bootstrap scripts.
- main.dart.js: Your compiled application’s main entry point.
- assets/: Your application assets.
- *.part.js: Numerous JavaScript chunks corresponding to your deferred libraries.
Unless you disable caching entirely, the browser caches all these files based on their URLs. The failure scenario typically unfolds like this:
- User loads Version 1: Browser downloads and caches main.dart.js (v1), some_feature.part.js (v1), etc.
- Deploy Version 2: You upload the new build output to your hosting.
- User revisits/refreshes: The browser might correctly fetch the updated index.html and even the new main.dart.js (v2). However, if the URL for some_feature.part.js hasn’t changed, the browser might decide its cached v1 version is still valid (based on cache headers).
- Runtime Crash: main.dart.js (v2) attempts to load the deferred feature, the browser provides some_feature.part.js (v1), and the mismatch causes a DeferredLoadException.
Standard cache-control headers (`Cache-Control: no-cache`, ETags) can help but often force revalidation for all assets on every load, potentially slowing down the user experience. Simply adding a version query parameter to main.dart.js (?v=1.2.3) doesn’t solve the core issue, as the URLs for the *.part.js files remain unchanged.
The most robust solution is to ensure that the URLs for all version-dependent assets change with every deployment. This forces the browser to fetch the new, consistent set.
Our approach is heavily inspired by discussions within the Flutter community, particularly the ongoing conversation in Flutter GitHub Issue #127459. The core idea is based on this post, and involves restructuring the build output and modifying the HTML `<base href>`.
Step 1: Post-Build Processing (`deploy/post-process.sh`)
After a successful `flutter build web`, we run a shell script (deploy/post-process.sh) that automates the restructuring:
1. Read Version: It extracts the version string (e.g., `1.30.11+504102`) from the `build/web/version.json` file generated by the build process.
# Get version from version.jsonVERSION=$(sed -n 's|.*"version":"\([^"]*\)".*|\1|p' "$FOLDER/version.json")
2. Create Distribution Directory: It creates a clean target distribution directory (e.g., `dist`).
rm -rf "$TARGET_FOLDER"mkdir "$TARGET_FOLDER"
3. Move Build Output: It moves the entire build/web contents into a subdirectory named after the version within the distribution directory.
# FOLDER=build/web, TARGET_FOLDER=distmv "$FOLDER" "$TARGET_FOLDER/$VERSION"
4. Elevate Key Files: It moves index.html and copies version.json from the versioned subdirectory back to the root of the distribution directory.
mv "$TARGET_FOLDER/$VERSION/index.html" "$TARGET_FOLDER"cp "$TARGET_FOLDER/$VERSION/version.json" "$TARGET_FOLDER"
5. Modify <base href>: This is the crucial step. The script uses `sed` to modify the <base> tag within dist/index.html.
<! - Before: →<base href="/" />
<! - After (Example): →
<base href="/1.30.11+504102/" /># On macOS, sed needs an explicit extension with -i
sed -i.bak "s|<base href=\"/\" />|<base href=\"/$VERSION/\" />|g" "$TARGET_FOLDER/index.html"
rm "$TARGET_FOLDER/index.html.bak"
Why this works: The `<base href>` tag specifies the base URL for all relative URLs within the document. By changing it from `/` to `/1.30.11+504102/`, all relative paths in index.html (like `<script src=”flutter_bootstrap.js”>` or implicitly loaded assets like fonts) and, critically, all assets requested dynamically by Flutter’s JavaScript (including main.dart.js and all *.part.js files) are now resolved relative to this unique versioned path. The browser sees URLs like
- https://yourdomain.com/1.30.11+504102/main.dart.js
- https://yourdomain.com/1.30.11+504102/main.dart.js_1.part.js
- etc.
Since the entire path segment changes with each deployment, the browser treats them as completely new resources, bypassing any cache conflicts related to older versions.
The beauty of using `<base href>` tag is that this does not change the URLs users see. All application paths remain stable and unchanged.
Step 2: Caching Configuration
Setting caching strategy correctly is crucial for performance and correctness:
- Root files (/index.html, /version.json): These should have a short cache duration or even `no-cache` to ensure users quickly receive the latest index.html with the correct `<base href>` pointing to the newest version.
- Versioned Assets (/<version>/**): All files within the versioned subdirectories (JS, fonts, images) can be cached aggressively, possibly even indefinitely, since they are essentially immutable.
The `<base href>` strategy ensures users get the latest version on new page loads or refreshes. To handle updates released while a user is actively using the application, we employ a background checking mechanism using a ValueNotifier (let’s call it AppUpdateChangeNotifier).
How it works:
- Periodic Fetch: A timer periodically triggers a function that fetches the /version.json file from the root of the hosted site (e.g., `https://yourdomain.com/version.json`). This file always contains the version identifier of the latest deployed build. A cache-busting query parameter is added to the request to ensure the actual latest version is fetched.
- Comparison: The fetched version string is parsed and compared against the version the currently running Flutter application was built with (simply save the version on the initial app load).
- State Update: If the fetched version is newer than the running version, the listeners get notified and act on it.
- UI Notification: In Faabul app, we chose to show a simple notification banner on top of the page, prompting the user to refresh the page. Click on a button will simply do `window.location.reload()`.
This ensures users are aware of new versions even during long sessions without interrupting their workflow until they choose to refresh.
The entire process is orchestrated by a main deployment script. The generic steps involved are:
- Prerequisites: Check credentials for deployment targets and storage.
- Clean & Build: Run `flutter clean` followed by `flutter build web` with appropriate flags (` — release`, ` — pwa-strategy none`).
- Post-Process: Execute the custom post process script to restructure the build output into the versioned directory format and update `<base href>`.
- Archive: Create an archive (e.g., tar.gz) of the newly created versioned build directory (e.g., `dist/1.30.11+504102`).
- Store Archive: Upload the archive to a cloud storage solution for history.
- Fetch Recent Archives: Download and unpack recent version archives from storage into the deployment directory (see Step 6).
- Deploy: Upload the contents of the processed distribution directory dist (containing index.html, version.json, and multiple versioned subdirectories) to the web hosting provider.
- Archiving: Storing build archives in cloud storage provides a historical record for auditing or manual rollbacks if a critical issue is found post-deployment.
- Keeping Recent Versions on Server: We keep the last few versions unpacked and available in the distribution directory alongside the latest one. Crucially, this is not primarily for rollbacks. Its main purpose is to serve deferred chunk requests (*.part.js) for users who still have an older version of the app open in their browser. If a user loaded version N-1 just before version N was deployed, their active session might still request chunks from /N-1/. By keeping recent versions available on the server, these active sessions can continue to function correctly.
- Monitoring: We log JS file sizes post each deploy to help us track bundle size evolution over time.
- Reliably Prevents Cache Issues: Effectively eliminates DeferredLoadException caused by stale *.part.js files.
- Instant Updates (on Refresh): Users get the latest version immediately upon refreshing or visiting the site anew.
- Mid-Session Update Notifications: Users are informed if an update occurs while they are active.
- Conceptually Simple: The core `<base href>` modification is straightforward.
- CDN Friendly: Works well with CDNs as asset URLs are unique per version.
- Easily extensible Having old versioned builds readily deployed can be easily leveraged for staged deployments or fast rollbacks.