Elevate Flutter PWA with a Seamless Offline Experience using Workbox Caching

Optimising Offline Performance of Flutter PWAs with Workbox Caching

Elevate Flutter PWA with a Seamless Offline Experience using Workbox Caching

Flutter Progressive Web Apps (PWAs) have gained significant popularity due to their ability to provide a seamless native user experience across various devices and platforms. In this blog, we'll explore how to optimize the offline performance and capabilities of your Flutter PWA through Workbox caching. Let's begin with the brief note on Flutter PWA.

Flutter PWA

A Flutter PWA is essentially a web app created using the Flutter framework that comes with a built-in service worker setup to serve basic capabilities. It combines the power of Flutter's UI capabilities with the reach and accessibility of the web.

Service Workers are scripts that run in the background, intercepting network requests, and allowing you to control how resources are cached and served to your application.

How's Flutter PWA unique from traditional Javascript framework PWA's

AspectsFlutterOther Javascript Frameworks
Rendering EngineFlutter uses Canvaskit and HTML rendererRelies on native rendering capabilities of web
Service WorkerFollows a Flutter-specific approach to caching and asset management as it involves its rendering enginesVarious popular techniques are available to simplify the configuration process as it uses conventional web engine
WorkboxWorkbox can still be used with Flutter PWA but some use cases still seem to be tricky because of the unique requirements of the frameworkWorkbox is commonly used for service worker configuration, offering a wide range of caching strategies
PWA SetupThe process of converting a Flutter web app into a PWA involves a unique set of configurations and dependencies that are specific to FlutterJavaScript framework PWAs have established practices and tooling for PWA setup.

Why is Caching Important?

Caching is crucial for PWAs because it enables applications to function seamlessly even when there's no internet connection. It allows the app to load previously visited pages or resources from the user's device, reducing loading times and providing uninterrupted usability.

Disadvantages of Flutter's Built-in Service Worker Setup

  • Limited customization options and control for caching strategies

  • Less flexibility for advanced scenarios.

This is where Workbox comes into play over Flutter's built-in service worker setup.

Workbox Caching

Workbox is a powerful library that simplifies service worker management and caching strategies.

Need

  • Workbox provides flexibility, advanced caching strategies, and efficient precaching, enhancing your Flutter PWA's offline capabilities.

  • It simplifies cache management, and cache cleanup and integrates with existing infrastructure.

Now that we've covered the basics, Workbox provides different techniques for configuration, among them we will explore the InjectManifest technique in this blog since it provides better control over other workbox caching options, especially in implementing advanced caching strategies.

Let's take a basic counter app in Flutter with a built-in service-worker setup as an example and add the required workbox and service-worker configurations to improve the offline functionality of the app.

To start with let's create a workbox-config.js in the web folder with the below configuration:

module.exports = {
  globDirectory: "build/web/",
  globPatterns: ["**/*.{json,otf,ttf,js,wasm,png,html}"],
  swSrc: "web/service-worker.js",
  swDest: "build/web/service-worker.js",
  maximumFileSizeToCacheInBytes: 10000000,
};

Each parameter serves a specific purpose for configuring the behaviour of Workbox in a service worker.

  • globDirectory: "build/web/" - Specifies the directory where the service worker should look for files to cache. In our case, it's the build/web directory.

  • globPatterns: ["**/*.{json,otf,ttf,js,wasm,png,html}"] - Defines an array of patterns for files to cache. It uses globs to match file names. In our case, file extensions are JSON, OTF, TTF, JS, WebAssembly (WASM), PNG, and HTML as these cover all types of files in the build/web directory.

  • swSrc: "web/service-worker.js" - Specifies the path to the source file of the service worker. This is the original service worker script that will be used as a basis for generating the final service worker.

  • swDest: "build/web/service-worker.js" - Sets the destination path for the generated service worker file. The final service worker will be created and stored in the build/web directory.

  • maximumFileSizeToCacheInBytes: 10000000 - Defines the maximum file size, in bytes, for files to be cached. Files larger than this size will not be included in the cache. In this case, it's set to 10,000,000 bytes (10 MB). Files larger than this threshold are typically excluded from the cache to prevent excessively large files from being cached.

Now we are done with the workbox config file that can be used to generate the final service worker with caching configuration. Let's start with the service worker setup.

  • Create a service-worker.js in the web folder and Import the Workbox library, specifically the service worker module using CDN.

      importScripts(
        "https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js"
      );
    
  • Use precacheAndRoute function which takes in the path of the files to be precached on the initial load of the app.

      const { precacheAndRoute } = workbox.precaching;
      CACHE_FILES = self.__WB_MANIFEST ?? [];
      precacheAndRoute(CACHE_FILES);
    
    💡
    self.__WB_MANIFEST is the placeholder for the array of files to be cached which will be added later during build by injecting workbox-config.js.
  • Listen to the install event to ensure immediate activation of the service worker using self.skipWaiting() and enable the service worker to claim control of the clients if any old service workers exist using self.clients.claim() in the activate event.

      self.addEventListener("install", function (event) {
        self.skipWaiting();
      });
    
      self.addEventListener("activate", () => {
        return self.clients.claim();
      });
    
  • To override Flutter's built-in service worker setup, Add serviceWorkerUrl to the configuration in the Flutter loader's loadEntrypoint function ( index.html ).

      window.addEventListener('load', function(ev) {
          // Download main.dart.js
          _flutter.loader.loadEntrypoint({
            serviceWorker: {
              serviceWorkerUrl: './service-worker.js',
              serviceWorkerVersion: serviceWorkerVersion,
            },
            onEntrypointLoaded: function(engineInitializer) {
              engineInitializer.initializeEngine().then(function(appRunner) {
                appRunner.runApp();
              });
            }
          });
        });
    
  • Now we are all set in terms of workbox and service worker. Let's build our Flutter web app without Flutter's builtin service worker setup using --pwa-strategy none option.

      flutter build web --pwa-strategy none --release
    
  • Then, Install the Workbox CLI with Node, run the following command in your terminal

      npm install workbox-cli --global
    
  • Run workbox inject manifest to populate the array of URLs to be cached that matches the glob pattern in place of self.__WB_MANIFEST in the destination service worker file in the build/web directory.

      workbox injectManifest web/workbox-config.js
    

    You can observe in the above log, that the service worker has added 26 URLs to precache in place of self.__WB_MANIFEST in the destination service worker file build/web/service-worker.js.

  • To test the offline functionality of our app locally, we can use http-server npm package to serve our build/web artifact directory, install it using the following command in your terminal

      npm install http-server --global
    
  • Run the below command in your terminal from the root directory of the app to serve the build artifact of our Flutter web app.

      http-server build/web/
    

    This will serve the app in the listed URLs with the port specified as shown in the below log, we can use the same to access our app in the local browser.

Storing Data in Offline

  • Offline storage is one of the key factors to improve the user's overall offline experience, there are several offline storage options for web apps the choice may vary in terms of storage capacity, features, and browser support.

    💡
    It's important to note that the storage capacity and browser support may change over time. Therefore, it's recommended to check the latest documentation and support status for each offline storage option.
  • Let's use localStorage in our app to store the _counter, to avoid data loss when the user goes offline or while refreshing the app. You can see the current logic of the counter in the below code.

      int _counter = 0;
    
      void _incrementCounter() {
        setState(() {
        // This call to setState tells the Flutter framework that something has
        // changed in this State, which causes it to rerun the build method below
        // so that the display can reflect the updated values. If we changed
        // _counter without calling setState(), then the build method would not be
        // called again, and so nothing would appear to happen.
        _counter++;
      });
    
  • To use localStorage in Flutter we can use the shared_preferences package, to add it open the pubspec.yaml file and add the following line under dependencies. Then run flutter pub get to download and install the package.

      dependencies:
        flutter:
          sdk: flutter
        shared_preferences: ^2.2.2
    
  • In main.dart, let's import and initialise the _counter with the value of the counter key in the local storage if not exists with 0.

      import 'package:shared_preferences/shared_preferences.dart';
    
  • Once we have an instance of SharedPreferences, we can use it to store data using the set method.

      int _counter = 0;
      late SharedPreferences prefs;
    
      @override
      void initState() {
        super.initState();
        // Load the counter value from SharedPreferences when the widget initializes.
        _loadCounter();
      }
    
      void _loadCounter() async {
        prefs = await SharedPreferences.getInstance();
        setState(() {
          _counter = prefs.getInt('counter') ?? 0;
        });
      }
    
      void _incrementCounter() async {
        setState(() {
          _counter++;
        });
    
        // Save the updated counter value to SharedPreferences.
        prefs.setInt('counter', _counter);
      }
    
  • Now the _counter will persist offline, even after refresh and between app sessions ensuring no data loss and giving users, the confidence to continue using the app without worrying about any external factors.

Other Suggestions:

  • Though we have done a basic configuration of service-worker with the help of workbox for flutter PWAs, workbox provides a lot of other options in terms of customising network strategies. Feel free to explore further on that.

  • Considering caching assets, especially images, it is better to use SVGs than PNGs for a seamless offline experience and to cache any other type of files in the app, we can straightaway include that in the globPattern of workbox config.

Checkout the live version of the app with offline capabilities on workbox-flutter-pwa.netlify.app

Github Repository: Mohanraj-Muthukumaran/Workbox-Flutter-PWA

Final Thoughts

In this blog, we highlighted how Workbox enhances cache management, allowing PWAs to function offline effectively over built-in service worker setups. To implement this we discussed InjectManifest technique, which provides precise control over caching. Additionally, we covered the importance of offline data storage to ensure data persistence even offline.

So, whether you're building a Flutter PWA for a personal project or a business application, integrating Workbox caching and offline data storage is a strategic move that will set your app apart. Your users will appreciate the reliability and smooth performance, making it a win-win for both developers and their audiences.