Skip to content
Tech Stack18 October 2026 · 14 min read

Clear Flutter User State Safely When Logging Out

Firebase signOut ends the session and leaves every provider, listener and cache where it was. Here is the order that clears them, and the late response that refills them anyway.

Clear Flutter User State Safely When Logging Out

Two people share a tablet. The first signs out, the second signs in, and for a moment the new session shows the previous person's favorites, their display name in the header, or a premium screen they never paid for. A second later the UI corrects itself. Nothing in the logs looks wrong.

Sign-out in a Flutter app is two separate jobs, and signOut only does the first one. The documentation for FirebaseAuth.signOut states one effect: it signs out the current user, and "if successful, it also updates any authStateChanges, idTokenChanges or userChanges stream listeners." Every provider, listener, cached image, saved preference and local draft in the app is the second job, and it is yours. To clear cached user data on logout in the right order:

  1. Raise a session marker so in-flight work can tell it is stale.
  2. Cancel session-scoped stream subscriptions and wait for their cleanup.
  3. Invalidate the providers the UI reads.
  4. Clear the caches your app owns, which is not the same set as the caches the SDKs own.
  5. Call signOut last, and route from the auth state event rather than from the button handler.

The order matters because steps 2 and 3 in the other sequence undo each other: clear the state first and a listener that is still open writes the previous user's document straight back into it.

Which data belongs to the session, and which belongs to the app

Before writing a teardown, sort every data source into one of two lifetimes. Session-scoped data must not survive sign-out. App-lifetime data, like a theme choice or an onboarding-seen flag, should survive it, and wiping it makes the next sign-in look broken in a different way.

SourceLifetimeWhat actually clears it
Firebase Auth sessionSessionsignOut, which updates the three auth streams
Stream subscriptions on user documentsSessioncancel() on each subscription
Provider or notifier state holding user dataSessionInvalidating or disposing the provider
Firestore local cacheSpans sessionsNothing on sign-out. Needs its own decision
Flutter image cacheSpans sessionsimageCache.clear() plus clearLiveImages()
Saved preferences and persisted app stateSpans sessionsRemoving the keys you wrote
Unsent local draftsDepends on your retention choiceA product decision, not a cache eviction

Two rows in that table surprise people. On native platforms the auth state itself is persisted on device between app restarts, as the Flutter auth setup guide describes, so a signed-in session is already designed to outlive the process. And the Firestore cache keeps its own copy: the offline persistence guide states plainly that "Cloud Firestore's cache isn't automatically cleared between sessions."

The order that keeps cleanup from undoing itself

Isometric diagram of five parallel channels feeding one plate, with sliding shutters closing across them at staggered positions and one shutter slightly crooked in its rail, while a single capsule that escaped the rightmost channel is turned aside by a tilted deflection plate before it reaches the plate

Write the teardown as one function that runs to completion, not as five actions spread across a button, a route guard and an initState. Each step has a reason to sit where it does.

Raise the session marker first. It is a plain integer that any async continuation can compare against. Nothing else in the sequence can stop work that has already left your code, so this is the only step that protects the ones after it.

Cancel subscriptions, and await the cancellation. The Dart documentation for StreamSubscription.cancel is specific: "After this call, the subscription no longer receives events", and the returned future "is completed once the stream has finished its cleanup." Awaiting it means the next step is not racing a listener that is still tearing down.

Invalidate provider state after the listeners are closed. If you use Riverpod, ref.invalidate "will destroy the current provider state", and whether a fresh state is created depends on whether anything is still listening, per the automatic disposal documentation. Automatic disposal is enabled by default with code generation and is opt-in otherwise, so do not assume a provider dies when the last screen closes. Check which mode your project is in.

Clear only the caches you own. SharedPreferences.clear() clears the preference store, so removing individual session keys is safer than clearing everything when the same store holds a theme or locale choice. For imagery, ImageCache.clear() "Evicts all pending and keepAlive entries from the cache", and live references need the separate clearLiveImages() call, because an image counts as live while its listener count has never dropped back to zero. The ImageCache reference also gives the defaults worth knowing before you touch them: up to 1000 images and 100 MB.

Sign out last, and route from the stream. The auth setup guide lists when authStateChanges fires, and one of those events is when the current user is signed out. Routing from that event, rather than from the tap handler, means a session that ends for any other reason, such as a disabled account or a token failure, takes the same path.

A late response is what refills a cleared cache

Here is the failure that survives a careful teardown. A profile screen starts a read, the user logs out half a second later, the teardown runs, and then the read completes and writes the previous user's name into state that was just emptied.

Cancelling the future does not solve this the way it reads. CancelableOperation from package:async wraps a future so you can cancel it, and after cancellation its value will not complete with either a result or an error. The class reference adds two conditions that matter here: if value has already completed, cancelling does nothing, and the page does not claim the underlying computation stops. The network call may well finish; you have only stopped listening to it. Awaiting a cancelled value directly hangs, which is why valueOrCancellation exists.

So make staleness checkable instead of relying on cancellation alone. Give each session an identity, carry it into the async work, and compare on the way back.

// Reference implementation, not a tested library. Run the checks at the
// end of this article against your own app before relying on it.

final sessionIdProvider = StateProvider<int>((ref) => 0);

class SessionGuard {
  SessionGuard(this._ref);
  final Ref _ref;

  int get current => _ref.read(sessionIdProvider);

  /// Awaits [work] and returns null if the session changed while it ran.
  Future<T?> guard<T>(Future<T> work) async {
    final startedIn = current;
    final result = await work;
    if (startedIn != current) return null;
    return result;
  }
}

A caller that writes to UI state goes through guard and treats null as "discard this result". The check costs one comparison and it covers every path, including the ones you forgot to make cancellable.

A teardown you can drop into an existing app

Collect session subscriptions in one place so cancellation is not a search through the widget tree.

class SessionSubscriptions {
  final _subs = <StreamSubscription<void>>[];

  void add(StreamSubscription<void> sub) => _subs.add(sub);

  Future<void> cancelAll() async {
    final pending = _subs.toList();
    _subs.clear();
    await Future.wait(pending.map((sub) => sub.cancel()));
  }
}

Then the teardown is readable top to bottom, and the order in it is the order from the section above.

Future<void> signOutAndClearSession(Ref ref) async {
  // 1. Any async result that started before this line is now stale.
  ref.read(sessionIdProvider.notifier).state++;

  // 2. Close listeners and wait for the streams to finish cleanup.
  await ref.read(sessionSubscriptionsProvider).cancelAll();

  // 3. Drop the state the UI is reading.
  for (final provider in sessionScopedProviders) {
    ref.invalidate(provider);
  }

  // 4. Clear the caches this app wrote, not the whole preference store.
  final prefs = await SharedPreferences.getInstance();
  for (final key in sessionPrefKeys) {
    await prefs.remove(key);
  }
  PaintingBinding.instance.imageCache
    ..clear()
    ..clearLiveImages();

  // 5. Sign out last. A listener on authStateChanges does the routing.
  await FirebaseAuth.instance.signOut();
}

sessionScopedProviders and sessionPrefKeys are lists you maintain by hand, and that is the point: a new provider holding user data is not covered until someone adds it, so keep both lists next to the teardown where a reviewer will see them.

Firestore's local cache is not part of sign-out

The SDK offers no clear-the-cache-now call for a logout handler, and the reason is in the preconditions of the one method that does clear it. On Android and Apple platforms Firestore's offline persistence is enabled by default, and on the web it is disabled by default, according to the offline data guide. The cache holds a copy of the data the app is actively using, and it is not cleared between sessions.

There is an API for clearing it, and its preconditions decide where you can call it. clearPersistence "Must be called while the FirebaseFirestore instance is not started", and on startup it must run before any other method except settings. If the instance is still running, the future fails. Its own documentation describes it as "primarily intended to help write reliable tests that use Cloud Firestore", and notes that it "does not attempt to securely overwrite or otherwise make cached data unrecoverable".

You can reach the not-started condition at runtime with terminate, after which clearPersistence is the only method that still works and "Any other method will throw a FirebaseException". Termination also does not cancel pending writes. For an app that holds sensitive per-user documents on a shared device, the honest options are: clear persistence during the next cold start while no Firestore call has happened yet, or do not enable persistence for that data at all, which is what the offline guide suggests for web apps handling sensitive information. Treat a clear-all on the logout button as the thing the SDK does not offer, rather than a line you are missing.

Drafts need a decision, not a delete

An unsent review, a half-filled form, a photo queued for upload: these are the user's work. Deleting them at sign-out loses data the person expected to find again. Leaving them on screen for the next account exposes one user's writing to another. Both are bugs, and a cache-eviction call picks one of them for you by accident.

Pick one of three, write it down, and make the UI say which one you chose:

  1. Keep the draft keyed by the owner's uid, hidden from any other session, and restored when that person signs back in on the same device.
  2. Discard it at sign-out after one explicit confirmation that names what will be lost.
  3. Flush it to the server before the teardown runs, and treat a failed flush as a reason to stop and ask.

Option 1 is the only one that keeps both properties, and it is also the one that puts previous-user content on the device deliberately. Your rules for it should be the same as for any other owned document, which is the next section.

What the FlutterFlow Logout action covers

In FlutterFlow, logout is a built-in action under Backend/Database > Firebase Authentication. The documentation for it says it "enables users to securely log out of their account and clear their session data from the app". Read that scope narrowly: the page does not mention App State, local state or where the user goes afterwards.

App State is the part worth checking in your own project. A persisted App State field is "saved to disk so that it can be loaded when the app is restarted", and the App State documentation lists authentication tokens among the things people keep there, with no runtime clearing step described. Non-persisted fields reset on restart, which is not the same as resetting at sign-out, because sign-out does not restart the process.

So the FlutterFlow build of this teardown is: the Logout action, plus an explicit Update App State action for every session-scoped field, plus a custom action for anything the editor cannot express, which is where cancelling your own stream subscriptions and clearing the image cache belongs. Check the generated code for your project rather than the version in any article, this one included, because the actions available in the editor change.

The server has to reject the read on its own

Client teardown stops the flash. It does not stop a request. A build that has the previous user's document id in memory can still ask for that document, and the only thing that can refuse is a rule.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId} {
      allow read, update, delete: if request.auth != null
        && request.auth.uid == userId;
      allow create: if request.auth != null;
    }
    match /favorites/{favoriteId} {
      allow read, update, delete: if request.auth != null
        && request.auth.uid == resource.data.ownerId;
      allow create: if request.auth != null
        && request.auth.uid == request.resource.data.ownerId;
    }
  }
}

One behavior of these rules decides how your queries have to be written. The rules conditions guide states that "security rules are not filters": a query is evaluated against its potential results and rejected if any of them could be unreadable for that client. A collection query over favorites therefore needs the owner constraint in the query itself, not only in the rule, or the whole query fails. The same guide notes that you are billed for document reads even when rules reject the request, so a loop retrying a rejected read costs money while returning nothing.

If the account switch also changes paid access, the identity you send to the billing SDK is a separate problem with its own failure mode. I wrote up that one in how to keep RevenueCat entitlements correct when users switch accounts, and the ownership model underneath all of this is compared across backends in Firestore rules versus Postgres row-level security for multi-tenant apps. The wider stack these pieces sit in is in this walkthrough of a Flutter and Firebase SaaS mobile build.

Running the switch test

Isometric diagram of two square plates side by side: the left one is dimmed and scuffed and still holds three translucent content cards, one with a peeled corner, while a glowing orb arcs from it toward the right plate and breaks apart against an upright wall between them, leaving the lit right plate empty except for its bare card slots

Use two synthetic accounts with obviously different content, and watch the first frame after each transition rather than the settled screen. A flash is the symptom; a settled screen hides it.

  1. Sign in as the first account, open the screens that cache content, and sign out. Sign in as the second account and look at the first frame of each of those screens. Any previous-user value means a provider or a listener outlived the teardown.
  2. Start a slow read, throttle the network if you need to, and sign out before it resolves. The result must be discarded rather than written into state.
  3. Sign out, force-stop the app, and relaunch. Content that reappears here came off disk, so it is in the Firestore cache, a preference key, or persisted app state.
  4. While signed in as the second account, request a document owned by the first. The server must reject it. Test the rule with the emulator and the rules unit-testing library instead of reading it and agreeing with yourself.
  5. Repeat the switch with the app backgrounded between sign-out and sign-in, since a resumed screen rebuilds from whatever state survived.

Check 4 is the one most often skipped, and it is the only one that still holds when the person requesting the data is not a cooperative user of the same device.

Where this breaks down

The teardown above is a reference implementation, not a tested library, and nothing in this article was executed against a device or a Firebase project. Treat the code as a shape to adapt and the checks above as the part that produces evidence in your project.

It also assumes the two users are cooperative people sharing a device. If one of them is motivated to read the other's data, client cleanup is not the control that matters, and neither is clearPersistence, which its own documentation says does not make cached data unrecoverable. For that threat the answer is to keep the sensitive documents out of the local cache in the first place, and to make the server rule the thing you test.

Start with the inventory table. Write your own version with the real provider names, preference keys and subscription sites from your project, then put that list in the same file as the teardown so a reviewer sees both at once. The teardown covers exactly what the list names, so the first useful change is making the list complete, and the first test to run against it is check 1.

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

Why does a Flutter provider still show the previous user's data after an account switch?

Because signing out does not destroy provider state on its own. The documented effect of signOut is that it signs out the current user and updates the three auth streams, so a provider holding the previous user's profile keeps that value until something invalidates or disposes it. With Riverpod, ref.invalidate destroys the current state, and whether a fresh state is built depends on whether anything is still listening. Automatic disposal is enabled by default only with code generation, so a provider can outlive the screen that created it. Keep an explicit list of session-scoped providers and invalidate every one of them inside the sign-out routine, after the listeners feeding them are cancelled.

How do you cancel async requests on logout in Flutter?

Cancel what is cancellable, then make the rest checkable. StreamSubscription.cancel() stops further events and returns a future that completes after the stream cleans up, so collect session subscriptions in one object and await their cancellation. A plain Future has no cancel, and CancelableOperation from package:async only stops you from receiving the result: its documentation says the value will not complete with a result or an error after cancellation, and does not claim the underlying work stops. So increment a session counter at the start of logout, capture it before each await, and discard any result whose captured value no longer matches.

Does Firebase signOut clear the Firestore cache?

No. The offline data guide states that Cloud Firestore's cache is not automatically cleared between sessions, and persistence is on by default on Android and Apple platforms. The one API that clears it, clearPersistence, must run while the instance is not started, and on startup it must come before every method except settings; if the instance is still running the future fails. You can reach that state at runtime with terminate, after which any other Firestore method throws. For sensitive per-user documents on a shared device, clearing at the next cold start or not enabling persistence for that data are the two honest options.

Should you clear SharedPreferences when a user logs out?

Clear the keys you wrote for that session, not the whole store. SharedPreferences.clear() empties the preference store, which also removes the theme, locale and onboarding flags that should survive a sign-out, so the next user meets an app that looks broken rather than clean. Keep a named list of session-scoped keys next to the sign-out routine and remove them individually. Apply the same reasoning to persisted FlutterFlow App State, which is written to local storage and reloaded at restart: each session-scoped field needs its own reset action, because the built-in Logout action's documentation does not mention App State at all.

How do you verify that nothing from the previous session survived?

Watch the first frame after each transition with two synthetic accounts, not the settled screen. Sign in as the first account, open every screen that caches content, sign out, sign in as the second, and look at the first frame of those same screens. Then repeat with a request still in flight when you sign out, and again with a force-stop and relaunch, which separates memory from disk. Finish with the check most people skip: while signed in as the second account, request a document owned by the first and confirm the server refuses it, tested against the emulator rather than read and approved by eye.