Skip to content

Flutter web - Need documentation on how to conditionally display a "Refresh. New Version is available." prompt when updated code is deployed to production. #104509

Open

Description

@Zelfapp

We need a foolproof way to be able to force a download of main.dart.js when code is updated and deployed to web live. E.g. if a user is on my flutter website and I deploy a new build and they click to a new screen, they should see new code updates without having to refresh their browser, or in the service worker js in web/index.html, be able to conditionally display a prompt to the user to "Refresh. New Version is available".

Google Messenger on web and other web interfaces are able to detect new code is deployed without a browser refresh and a "Refresh for new version" prompt is displayed. We need this ability via the service worker js code in web/index.html.

In typical html sites the foolproof way to force js/css updates, while a user is actively on your site, is to change the js/css filename reference, which forces a download of the new js/css code immediately, when the user navigates to any new screen on your site. This pattern does not require a prompt at all, but if we can't do that in flutter web, having the ability to display a refresh now prompt would be just fine.

This is a serious issue, for obvious reasons. If users are not getting updated code after a build and deploy to live hosting and we have to hope that users will press their browser cache refresh button, at some point, then there will be all sorts of problems.

It would be great for this behavior in web/index.html to occur by simply changing the version number in pubspec.yaml, and the flutter build web and deploy to hosting would guarantee the user would get new code base when they navigate to a new screen or a refresh prompt.

This is web/index.html

var serviceWorkerVersion = null; after running flutter build web does get a new version number on each build, but this is not forcing the serviceWorker to refresh main.dart.js where all code updates should be after a new build and deploy to live hosting. Only a browser refresh where the user is required to press the browser refresh button or clear the cache does main.dart.js update with new deployed code.

The console.log('Installed new service worker.'); only runs when you do a full browser refresh. This never runs after a deploy with new code to hosting without a full browser refresh. E.g. if you deploy new code to hosting and then click around without a browser refresh it will not log a new service worker is installed.

<script>
  var serviceWorkerVersion = null;
  var scriptLoaded = false;
  function loadMainDartJs() {
    if (scriptLoaded) {
      return;
    }
    scriptLoaded = true;
    var scriptTag = document.createElement('script');
    scriptTag.src = 'main.dart.js';
    scriptTag.type = 'application/javascript';
    document.body.append(scriptTag);
  }

  if ('serviceWorker' in navigator) {
    // Service workers are supported. Use them.
    window.addEventListener('load', function () {
      // Wait for registration to finish before dropping the <script> tag.
      // Otherwise, the browser will load the script multiple times,
      // potentially different versions.
      var serviceWorkerUrl = 'flutter_service_worker.js?v=' + serviceWorkerVersion;
      navigator.serviceWorker.register(serviceWorkerUrl)
        .then((reg) => {
          function waitForActivation(serviceWorker) {
            serviceWorker.addEventListener('statechange', () => {
              if (serviceWorker.state == 'activated') {
                console.log('Installed new service worker.');
                loadMainDartJs();
              }
            });
          }
          if (!reg.active && (reg.installing || reg.waiting)) {
            // No active web worker and we have installed or are installing
            // one for the first time. Simply wait for it to activate.
            waitForActivation(reg.installing || reg.waiting);
          } else if (!reg.active.scriptURL.endsWith(serviceWorkerVersion)) {
            // When the app updates the serviceWorkerVersion changes, so we
            // need to ask the service worker to update.
            console.log('New service worker available.');
            reg.update();
            waitForActivation(reg.installing);
          } else {
            // Existing service worker is still good.
            console.log('Loading app from service worker.');
            loadMainDartJs();
          }
        });

      // If service worker doesn't succeed in a reasonable amount of time,
      // fallback to plaint <script> tag.
      setTimeout(() => {
        if (!scriptLoaded) {
          console.warn(
            'Failed to load app from service worker. Falling back to plain <script> tag.',
          );
          loadMainDartJs();
        }
      }, 4000);
    });
  } else {
    // Service workers not supported. Just drop the <script> tag.
    loadMainDartJs();
  }
</script>

Logs

flutter doctor -v
[✓] Flutter (Channel stable, 3.0.1, on Manjaro Linux 5.4.192-1-MANJARO, locale en_US.UTF-8)
    • Flutter version 3.0.1 at /opt/flutter
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision fb57da5f94 (5 days ago), 2022-05-19 15:50:29 -0700
    • Engine revision caaafc5604
    • Dart version 2.17.1
    • DevTools version 2.12.2

[✓] Android toolchain - develop for Android devices (Android SDK version 31.0.0-rc2)
    • Android SDK at /home/nate/Android/Sdk
    • Platform android-31, build-tools 31.0.0-rc2
    • ANDROID_HOME = /opt/android-sdk
    • ANDROID_SDK_ROOT = /home/nate/Android/Sdk
    • Java binary at: /opt/android-studio/jre/bin/java
    • Java version OpenJDK Runtime Environment (build 11.0.12+0-b1504.28-7817840)
    • All Android licenses accepted.

[✓] Chrome - develop for the web
    • CHROME_EXECUTABLE = /usr/bin/google-chrome-stable

[✓] Linux toolchain - develop for Linux desktop
    • clang version 13.0.1
    • cmake version 3.23.1
    • ninja version 1.10.2
    • pkg-config version 1.8.0

[✓] Android Studio (version 2021.2)
    • Android Studio at /opt/android-studio
    • Flutter plugin version 67.1.2
    • Dart plugin version 212.5744
    • Java version OpenJDK Runtime Environment (build 11.0.12+0-b1504.28-7817840)

[✓] Connected device (3 available)
    • sdk gphone x86 64 arm64 (mobile) • emulator-5554 • android-x64    • Android 11 (API 30) (emulator)
    • Linux (desktop)                  • linux         • linux-x64      • Manjaro Linux 5.4.192-1-MANJARO
    • Chrome (web)                     • chrome        • web-javascript • Google Chrome 101.0.4951.64

[✓] HTTP Host Availability
    • All required HTTP hosts are available

• No issues found!

Note: I use firebase hosting. I tried adding the following headers to my firebase.json to prevent caching, but neither header forces a refresh of code updates deployed live.

"headers": [
  { "source":"/main.dart.js", "headers": [{"key": "Cache-Control", "value": "no-cache"}] }
]

"headers": [
  { "source":"/service-worker.js", "headers": [{"key": "Cache-Control", "value": "no-cache"}] }
]

Activity

  1. darshankawar commented on May 25, 2022

    @darshankawar
    Member

    @Zelfapp
    Thanks for the report. Can you take a look at this similar issue and specifically this issue comment and check if it helps to answer your question ?

  2. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on May 25, 2022
  3. Zelfapp commented on May 25, 2022

    @Zelfapp
    Author

    Nothing is jumping out to me on either of those threads and I had visited those before posting my report. It would be great to be able to display a "new version refresh now" prompt. That is exactly what I'm looking for.

    @darshankawar unless I'm missing something, this condition is never met without a browser refresh to see that there is new code deployed. I have been testing this and feel pretty confident this condition is never caught without a browser refresh. To test, I've been deploying code updates to web hosting, and clicking around my site watching the console logs, but this condition is never caught clicking around the site. It is only caught when there is a browser refresh, so displaying a prompt when this condition is caught is pointless because the browser has already been refreshed to hit this condition.

    if (serviceWorker.state == 'activated') {
      // This does not run without a browser refresh, so newly deployed code will not be downloaded.
      console.log('Installed new service worker.');
      loadMainDartJs();
              // if there's an existing controller (previous Service Worker), show the prompt 
            // you can tweak this and delay the notification once the page is load you can show a notification and ask for a new refresh
                if (navigator.serviceWorker.controller) {
                  // you have a better UI here, reloading is not a great user experince here.
                  const confirmed = alert('New version of the app is available. Refresh now');
                  if (confirmed == true) {
                    window.location.reload();
                  }
            }
    }

    This is interesting and appears to solve the issue of recognizing new code has been deployed without a browser refresh, and will display a prompt, but where is the documentation on flutter.dev to implement this for flutter web? Or this appears to be "non-flutter core" work that at one point was under development, but I can't seem to find it on pub.dev or anywhere else.

  4. removed
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on May 25, 2022
  5. darshankawar commented on May 25, 2022

    @darshankawar
    Member

    Thanks. The PR you linked above was pretty old and seems he never came back to it, so the status still stands as-is.
    I am going to label this as documentation request.

  6. added
    platform-webWeb applications specifically
    and removed
    in triagePresently being triaged by the triage team
    on May 25, 2022
  7. changed the title [-]Flutter web - Need documentation on how to programmatically clear cache[/-] [+]Flutter web - Need documentation on how to conditionally display a "Refresh. New Version is available." prompt when updated code is deployed to production.[/+] on May 25, 2022
  8. added
    P3Issues that are less important to the Flutter project
    on May 28, 2022
  9. MingSern commented on Oct 14, 2022

    @MingSern
  10. kevmoo commented on Nov 8, 2022

    @kevmoo
    Contributor

    I'm adding this to my tracking list. We'll either update the docs or fix the framework – or both if needed! Thanks for your patience!

  11. self-assigned this
    on Nov 8, 2022
  12. yog-ka commented on Dec 15, 2022

    @yog-ka

    Any changes in version 3.x?

  13. 69 remaining items

  14. DavidOrakpo commented on Oct 29, 2025

    @DavidOrakpo
  15. kevmoo commented on Oct 29, 2025

    @kevmoo
    Contributor

    This would be a GREAT issue for a community member to tackle.

    I'm pretty sure this is a solved problem for any SPA/PWA. We just need to understand the Flutter-specific glue.

  16. dsyrstad commented on Oct 29, 2025

    @dsyrstad

    @kevmoo @DavidOrakpo For a solution, see my comment above: #104509 (comment). This solution has been working for us for a few months now.

  17. DavidOrakpo commented on Oct 29, 2025

    @DavidOrakpo

    @kevmoo @DavidOrakpo For a solution, see my comment above: #104509 (comment). This solution has been working for us for a few months now.

    Thank you! Can you go into more detail in how you built and set up your REST service? How do I replicate it

  18. luis901101 commented on Oct 29, 2025

    @luis901101
    Contributor

    Hi, sorry if this is out of context but I have been using pwa_update_listener since always and it works just fine. I just made a PR today to use package:web/web.dart instead of deprecated dart:html.

  19. dsyrstad commented on Oct 29, 2025

    @dsyrstad

    @DavidOrakpo

    Thank you! Can you go into more detail in how you built and set up your REST service? How do I replicate it

    We have a database table that stores the current versions for web, Android, and iOS (e.g. "1.2.22+167" - "<version>+<build-number>"). We store these as distinct values for each platform. When you query the REST endpoint, we simply query the table and return the versions as JSON. We have separate versions for each platform due to publishing delays for Android and iOS. We also have a "minimum required version" with an effective date. This is usually an older version (one version back) that the user must, at minimum, be running and must upgrade from if their version is less than the minimum. If they are above the minimum required version, but still not up to date, we just recommend that they update.

    On the Flutter side, we generate the pubspec version and build number as consts in the code. This is because other methods like reading and comparing version.json as described elsewhere in this issue lead to getting out of date versions due to browser caching issues. Whatever is actually in your code is what you're running, not some externally cached file. We then compare the const build number with the one we received from the REST call for the applicable platform. If the server-provided build number is greater than the app's const build number, the user is prompted to upgrade and then updateApp() (see my comment) is invoked.

  20. DavidOrakpo commented on Oct 29, 2025

    @DavidOrakpo

    Thank you! I'd give this a shot and revert

    @DavidOrakpo

    Thank you! Can you go into more detail in how you built and set up your REST service? How do I replicate it

    We have a database table that stores the current versions for web, Android, and iOS (e.g. "1.2.22+167" - "<version>+<build-number>"). We store these as distinct values for each platform. When you query the REST endpoint, we simply query the table and return the versions as JSON. We have separate versions for each platform due to publishing delays for Android and iOS. We also have a "minimum required version" with an effective date. This is usually an older version (one version back) that the user must, at minimum, be running and must upgrade from if their version is less than the minimum. If they are above the minimum required version, but still not up to date, we just recommend that they update.

    On the Flutter side, we generate the pubspec version and build number as consts in the code. This is because other methods like reading and comparing version.json as described elsewhere in this issue lead to getting out of date versions due to browser caching issues. Whatever is actually in your code is what you're running, not some externally cached file. We then compare the const build number with the one we received from the REST call for the applicable platform. If the server-provided build number is greater than the app's const build number, the user is prompted to upgrade and then updateApp() (see my comment) is invoked.

  21. DavidOrakpo commented on Oct 30, 2025

    @DavidOrakpo

    @DavidOrakpo

    Thank you! Can you go into more detail in how you built and set up your REST service? How do I replicate it

    We have a database table that stores the current versions for web, Android, and iOS (e.g. "1.2.22+167" - "+"). We store these as distinct values for each platform. When you query the REST endpoint, we simply query the table and return the versions as JSON. We have separate versions for each platform due to publishing delays for Android and iOS. We also have a "minimum required version" with an effective date. This is usually an older version (one version back) that the user must, at minimum, be running and must upgrade from if their version is less than the minimum. If they are above the minimum required version, but still not up to date, we just recommend that they update.

    On the Flutter side, we generate the pubspec version and build number as consts in the code. This is because other methods like reading and comparing version.json as described elsewhere in this issue lead to getting out of date versions due to browser caching issues. Whatever is actually in your code is what you're running, not some externally cached file. We then compare the const build number with the one we received from the REST call for the applicable platform. If the server-provided build number is greater than the app's const build number, the user is prompted to upgrade and then updateApp() (see my comment) is invoked.

    24 hours later, and it works! Awesome!

  22. anggaaryas commented on Mar 25, 2026

    @anggaaryas

    use this method solve my problem, so the version will be automatically changed when there are new build

    <body>
      <script>
        (function () {
          const rawServiceWorkerVersion = {{flutter_service_worker_version}};
          const versionToken =
            typeof rawServiceWorkerVersion === 'string' &&
                rawServiceWorkerVersion.trim() &&
                rawServiceWorkerVersion.trim() !== 'null'
              ? rawServiceWorkerVersion.trim()
              : Date.now().toString();
    
          const bootstrapUrl = new URL('flutter_bootstrap.js', document.baseURI);
          bootstrapUrl.searchParams.set('v', versionToken);
    
          const scriptTag = document.createElement('script');
          scriptTag.src = bootstrapUrl.toString();
          scriptTag.async = true;
          document.body.appendChild(scriptTag);
        })();
      </script>
    </body>
  23. zs-dima commented on May 8, 2026

    @zs-dima

    @Zelfapp @kevmoo, please, take a look at a significantly improved solution:
    https://pub.dev/packages/sw
    this package solves Cache invalidation and displays an example "Refresh. New Version is available."

  24. philitell commented on May 11, 2026

    @philitell

    Is there an official recommendation from flutter how to solve this?

  25. seba-db commented on May 11, 2026

    @seba-db

    Here is my solution. Has been working great so far. The solution works by creating a version.json file that stores the last version and storing the last detected in SharedPreferences. Then polls the last version every 5 minutes. The key files (main and bootstrap) are renamed to include a hash, so every new commit has a new file, and the cache invalidates them.

    I am including here the script to run after the build, the service, provider and banner to add to your app. Took me several tries to have something that truly works. Working now on Vercel.

    Cache Buster:

    
    # cache-buster.sh
    # Generates version.json and renames Flutter build artifacts with hash for cache-busting
    # Usage: bash scripts/cache-buster.sh build/web
    
    set -e
    
    BUILD_DIR="${1:-build/web}"
    
    if [ ! -d "$BUILD_DIR" ]; then
      echo "Error: Build directory not found: $BUILD_DIR"
      exit 1
    fi
    
    # Extract git info
    COMMIT_SHA=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
    BUILD_HASH="${COMMIT_SHA:0:7}"
    BUILD_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
    
    # Create deployment ID from timestamp and hash
    DEPLOYMENT_ID="flutter-$(date +%Y%m%d)-${BUILD_HASH}"
    
    # Generate version.json
    VERSION_JSON=$(cat <<EOF
    {
      "deploymentId": "$DEPLOYMENT_ID",
      "buildHash": "$BUILD_HASH",
      "buildTimestamp": "$BUILD_TIMESTAMP",
      "commitSha": "$COMMIT_SHA"
    }
    EOF
    )
    
    # Write version.json to build output
    echo "$VERSION_JSON" > "$BUILD_DIR/version.json"
    
    echo "✓ Generated $BUILD_DIR/version.json"
    echo "  Deployment ID: $DEPLOYMENT_ID"
    echo "  Build Hash: $BUILD_HASH"
    echo "  Timestamp: $BUILD_TIMESTAMP"
    
    # Cache-bust main files by renaming with hash
    # NOTE: Do NOT rename flutter_service_worker.js - it must stay at a known path for browser registration
    echo ""
    echo "🔄 Renaming artifacts for cache-busting..."
    
    # Step 1: Update flutter_bootstrap.js to reference the hashed main.dart.js BEFORE renaming
    if [ -f "$BUILD_DIR/flutter_bootstrap.js" ]; then
      MAIN_DART_NEW_NAME="main.dart-${BUILD_HASH}.js"
      sed -i.bak "s|main\.dart\.js|${MAIN_DART_NEW_NAME}|g" "$BUILD_DIR/flutter_bootstrap.js"
      rm -f "$BUILD_DIR/flutter_bootstrap.js.bak"
      echo "  ✓ Updated flutter_bootstrap.js to reference $MAIN_DART_NEW_NAME"
    fi
    
    # Step 2: Rename main.dart.js and flutter_bootstrap.js with hash
    if [ -f "$BUILD_DIR/main.dart.js" ]; then
      mv "$BUILD_DIR/main.dart.js" "$BUILD_DIR/main.dart-${BUILD_HASH}.js"
      echo "  ✓ main.dart.js → main.dart-${BUILD_HASH}.js"
    fi
    
    if [ -f "$BUILD_DIR/flutter_bootstrap.js" ]; then
      mv "$BUILD_DIR/flutter_bootstrap.js" "$BUILD_DIR/flutter_bootstrap-${BUILD_HASH}.js"
      echo "  ✓ flutter_bootstrap.js → flutter_bootstrap-${BUILD_HASH}.js"
    fi
    
    # Step 3: Update index.html to reference renamed flutter_bootstrap file
    if [ -f "$BUILD_DIR/index.html" ]; then
      sed -i.bak "s|flutter_bootstrap\.js|flutter_bootstrap-${BUILD_HASH}.js|g" "$BUILD_DIR/index.html"
      rm -f "$BUILD_DIR/index.html.bak"
      echo "  ✓ Updated index.html to reference flutter_bootstrap-${BUILD_HASH}.js"
    fi
    
    echo ""
    echo "✅ Cache-busting complete"
    

    Version Check Service:

    import 'package:http/http.dart' as http;
    
    class VersionCheckService {
      final http.Client _httpClient;
      final Duration timeout;
    
      VersionCheckService({
        http.Client? httpClient,
        this.timeout = const Duration(seconds: 5),
      }) : _httpClient = httpClient ?? http.Client();
    
      /// Fetches version.json from the same origin as the Flutter web app.
      /// Returns the deploymentId if successful, null if any error occurs (silently).
      Future<String?> fetchDeploymentId(String baseUrl) async {
        try {
          // Construct URL with cache-busting query parameter (current timestamp in ms)
          final timestamp = DateTime.now().millisecondsSinceEpoch;
          final versionUrl = Uri.parse('$baseUrl/version.json?t=$timestamp');
    
          final response = await _httpClient
              .get(versionUrl)
              .timeout(timeout);
    
          if (response.statusCode != 200) {
            return null;
          }
    
          final json = jsonDecode(response.body) as Map<String, dynamic>;
          final deploymentId = json['deploymentId'] as String?;
    
          return deploymentId;
        } catch (e) {
          // Silently fail on any error (network, parsing, etc.)
          return null;
        }
      }
    
      /// Fetches the full version.json object.
      /// Returns null if any error occurs (silently).
      Future<VersionJson?> fetchVersion(String baseUrl) async {
        try {
          // Construct URL with cache-busting query parameter (current timestamp in ms)
          final timestamp = DateTime.now().millisecondsSinceEpoch;
          final versionUrl = Uri.parse('$baseUrl/version.json?t=$timestamp');
    
          final response = await _httpClient
              .get(versionUrl)
              .timeout(timeout);
    
          if (response.statusCode != 200) {
            return null;
          }
    
          final json = jsonDecode(response.body) as Map<String, dynamic>;
    
          return VersionJson(
            deploymentId: json['deploymentId'] as String? ?? '',
            buildHash: json['buildHash'] as String? ?? '',
            buildTimestamp: json['buildTimestamp'] as String? ?? '',
            commitSha: json['commitSha'] as String? ?? '',
          );
        } catch (e) {
          // Silently fail on any error
          return null;
        }
      }
    }
    
    class VersionJson {
      final String deploymentId;
      final String buildHash;
      final String buildTimestamp;
      final String commitSha;
    
      VersionJson({
        required this.deploymentId,
        required this.buildHash,
        required this.buildTimestamp,
        required this.commitSha,
      });
    
      @override
      String toString() {
        return 'VersionJson(deploymentId: $deploymentId, buildHash: $buildHash, buildTimestamp: $buildTimestamp, commitSha: $commitSha)';
      }
    }
    

    Version Check provider:

    import 'dart:html' as html;
    import 'package:flutter/foundation.dart';
    import 'package:flutter/material.dart';
    import 'package:flutter_dotenv/flutter_dotenv.dart';
    import 'package:shared_preferences/shared_preferences.dart';
    import '../services/version_check_service.dart';
    import '../utils/logger_utils.dart';
    
    class VersionCheckProvider extends ChangeNotifier {
      static const String _storageKey = 'app_deployment_id';
    
      final VersionCheckService _versionService;
      final String _baseUrl;
    
      Timer? _pollTimer;
      String? _storedDeploymentId;
      String? _latestServerDeploymentId;
      bool _isUpdateAvailable = false;
      bool _hasInitialized = false;
    
      VersionCheckProvider({
        required VersionCheckService versionService,
        required String baseUrl,
      })  : _versionService = versionService,
            _baseUrl = baseUrl;
    
      bool get isUpdateAvailable => _isUpdateAvailable;
    
      @override
      void dispose() {
        _pollTimer?.cancel();
        super.dispose();
      }
    
      /// Initialize polling based on environment (prod vs dev).
      /// Should be called after app initialization is complete.
      Future<void> startPolling() async {
        // Skip version checking when running on localhost (local development)
        if (_isLocalhost()) {
          LoggerUtil.d('[VersionCheck] Skipping version check — running on localhost');
          return;
        }
    
        // Load stored deployment ID from SharedPreferences
        await _loadStoredDeploymentId();
    
        // Perform first check immediately
        _checkVersion();
    
        // Then set up periodic polling
        final pollInterval = _getPollInterval();
        _pollTimer = Timer.periodic(pollInterval, (_) {
          _checkVersion();
        });
    
        // Handle app lifecycle pausing/resuming
        _setupLifecycleListener();
      }
    
      /// Load the stored deployment ID from SharedPreferences
      Future<void> _loadStoredDeploymentId() async {
        try {
          final prefs = await SharedPreferences.getInstance();
          _storedDeploymentId = prefs.getString(_storageKey);
          if (!_isProdEnvironment()) {
            LoggerUtil.d(
              '[VersionCheck] Loaded stored deploymentId: $_storedDeploymentId',
            );
          }
        } catch (e) {
          if (!_isProdEnvironment()) {
            LoggerUtil.w('[VersionCheck] Failed to load stored deploymentId: $e');
          }
        }
      }
    
      /// Save deployment ID to SharedPreferences
      Future<void> _saveDeploymentId(String deploymentId) async {
        try {
          final prefs = await SharedPreferences.getInstance();
          await prefs.setString(_storageKey, deploymentId);
          if (!_isProdEnvironment()) {
            LoggerUtil.d('[VersionCheck] Saved deploymentId: $deploymentId');
          }
        } catch (e) {
          if (!_isProdEnvironment()) {
            LoggerUtil.w('[VersionCheck] Failed to save deploymentId: $e');
          }
        }
      }
    
      /// Pause polling when app is backgrounded
      void pausePolling() {
        _pollTimer?.cancel();
        _pollTimer = null;
      }
    
      /// Resume polling when app is foregrounded
      void resumePolling() {
        if (_pollTimer == null) {
          startPolling();
        }
      }
    
      void _setupLifecycleListener() {
        WidgetsBinding.instance.addObserver(
          _AppLifecycleObserver(
            onPaused: pausePolling,
            onResumed: resumePolling,
          ),
        );
      }
    
      /// Fetch and compare current version against stored version
      Future<void> _checkVersion() async {
        final serverDeploymentId =
            await _versionService.fetchDeploymentId(_baseUrl);
    
        final isProd = _isProdEnvironment();
    
        if (!isProd) {
          LoggerUtil.d(
            '[VersionCheck] Fetch result: serverDeploymentId=$serverDeploymentId, '
            'storedDeploymentId=$_storedDeploymentId, '
            'hasInitialized=$_hasInitialized',
          );
        }
    
        if (serverDeploymentId != null) {
          // First time seeing any deployment ID: just store it, don't show banner
          if (_storedDeploymentId == null) {
            _storedDeploymentId = serverDeploymentId;
            await _saveDeploymentId(serverDeploymentId);
            _hasInitialized = true;
            _isUpdateAvailable = false;
            if (!isProd) {
              LoggerUtil.d(
                '[VersionCheck] First initialization - stored serverDeploymentId=$serverDeploymentId, '
                'banner will NOT show on first load',
              );
            }
            return;
          }
    
          // Subsequent checks: compare stored vs server and show banner if different
          final updateAvailable = _storedDeploymentId != serverDeploymentId;
    
          if (!isProd) {
            LoggerUtil.d(
              '[VersionCheck] Comparing: storedDeploymentId=$_storedDeploymentId '
              'vs serverDeploymentId=$serverDeploymentId, '
              'updateAvailable=$updateAvailable, wasAvailable=$_isUpdateAvailable',
            );
          }
    
          if (updateAvailable) {
            _latestServerDeploymentId = serverDeploymentId;
          }
    
          if (updateAvailable != _isUpdateAvailable) {
            _isUpdateAvailable = updateAvailable;
            if (!isProd) {
              LoggerUtil.i(
                '[VersionCheck] Update status changed: isUpdateAvailable=$_isUpdateAvailable',
              );
            }
            notifyListeners();
          }
        } else {
          if (!isProd) {
            LoggerUtil.w(
              '[VersionCheck] Failed to fetch deploymentId from $_baseUrl/version.json',
            );
          }
        }
        // If fetch fails (null), keep existing state and try again next interval
      }
    
      /// Whether the app is running on localhost (local development).
      /// When true, version checking is skipped entirely since there's no
      /// deployed version.json to compare against.
      bool _isLocalhost() {
        return _baseUrl.contains('localhost') || _baseUrl.contains('127.0.0.1');
      }
    
      bool _isProdEnvironment() {
        final graphqlServer = dotenv.env['GRAPHQL_SERVER'] ?? '';
        return graphqlServer.contains('.prod.') ||
            graphqlServer.contains('yourdomain.com');
      }
    
      /// Determine poll frequency based on environment
      Duration _getPollInterval() {
        final graphqlServer = dotenv.env['GRAPHQL_SERVER'] ?? '';
        final isProd = graphqlServer.contains('.prod.') ||
            graphqlServer.contains('yourdomain.com');
    
        final interval =
            isProd ? const Duration(minutes: 5) : const Duration(seconds: 30);
    
        if (!isProd) {
          LoggerUtil.d(
            '[VersionCheck] Poll interval: ${interval.inSeconds}s (dev mode), '
            'fetching from: $_baseUrl/version.json',
          );
        }
        return interval;
      }
    
      /// Save the latest server deployment ID and reload the app
      Future<void> reloadApp() async {
        if (_latestServerDeploymentId != null) {
          await _saveDeploymentId(_latestServerDeploymentId!);
        }
        html.window.location.reload();
      }
    }
    
    /// App lifecycle observer to pause/resume polling
    class _AppLifecycleObserver with WidgetsBindingObserver {
      final VoidCallback onPaused;
      final VoidCallback onResumed;
    
      _AppLifecycleObserver({
        required this.onPaused,
        required this.onResumed,
      });
    
      @override
      void didChangeAppLifecycleState(AppLifecycleState state) {
        switch (state) {
          case AppLifecycleState.paused:
            onPaused();
          case AppLifecycleState.resumed:
            onResumed();
          default:
            break;
        }
      }
    }
    

    Banner Widget:

    import 'package:provider/provider.dart';
    import '../providers/version_check_provider.dart';
    import '../theme/text_styles.dart';
    
    class VersionCheckBanner extends StatelessWidget {
      const VersionCheckBanner({Key? key}) : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        return Consumer<VersionCheckProvider>(
          builder: (context, provider, _) {
            if (!provider.isUpdateAvailable) {
              return const SizedBox.shrink();
            }
    
            return Material(
              color: const Color(0xFFFFA500), // Amber/orange
              child: Padding(
                padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
                child: Row(
                  children: [
                    Expanded(
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          BodyLarge(
                            'A new version of the app is available.',
                            color: TextStyleColor.white,
                            isBold: true,
                          ),
                          const SizedBox(height: 4),
                          BodySmall(
                            'Please reload to update.',
                            color: TextStyleColor.white,
                          ),
                        ],
                      ),
                    ),
                    const SizedBox(width: 12),
                    ElevatedButton(
                      onPressed: () => provider.reloadApp(),
                      style: ElevatedButton.styleFrom(
                        backgroundColor: Colors.white,
                        foregroundColor: const Color(0xFFFFA500),
                        padding:
                            const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
                      ),
                      child: const BodyMedium('Reload', isBold: true),
                    ),
                  ],
                ),
              ),
            );
          },
        );
      }
    }
    
  26. Ortes commented on Jun 12, 2026

    @Ortes
    Contributor

    For anyone landing here: the underlying blocker for a reliable "new version available" prompt on web is that the running bundle can't know its own version — package_info_plus reads version.json, fetched from the server, which reflects the deployed version rather than the executing (cached) bundle.

    Proposals to fix this at the source: #187935 (tool injects FLUTTER_BUILD_NAME/FLUTTER_BUILD_NUMBER, like appFlavor) and dart-lang/sdk#38855 (+CL https://dart-review.googlesource.com/c/sdk/+/512380, dart.package.version for the Dart CLI). Either makes the running-vs-deployed comparison trivial; package_info_plus is ready to consume them (fluttercommunity/plus_plugins#3874).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Issues that are less important to the Flutter projectcustomer: crowdAffects or could affect many people, though not necessarily a specific customer.d: api docsIssues with https://api.flutter.dev/platform-webWeb applications specificallyteam-webOwned by Web platform teamtriaged-webTriaged by Web platform team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions