← Blog

Why Customers Came Back as Guests, and What Logout Must Remove

7 min read
Watch the walkthrough on YouTube

A customer logs into your mobile application, browses their account, and closes the app. When they reopen it ten minutes later, they are greeted as a guest. Their access token in the secure Keychain is completely valid, but your Redux store behaves as if they never signed in.

Persisted state needs a versioned migration on the way in and a deliberate purge on sign-out. Session expiry clears the token only.

This is Day 12 of Ship Native, the second lesson in our three-part module on State and offline architecture. Yesterday in Day 11: Why the Cart Quantity Jumps Back After You Tap +, we tackled pending local state and request collapsing. Today we examine the lifecycle of persisted mobile storage: why redux-persist migrations fail when written as bare functions, why setting dropped keys to undefined breaks your reducers, and how to structure a complete logout pipeline that leaves zero data behind.

The companion video features schematic diagrams, synthetic narration, and timed bilingual subtitles. The code examples below represent production patterns tested in active consumer apps.

The symptom: guest on relaunch

In our retail application Liana, a migration released in August caused an immediate spike in customer confusion. Users who had successfully signed in were unexpectedly returned to an unauthenticated guest state every time the application restarted.

The authentication tokens stored in the iOS Keychain and Android Keystore remained intact. Yet the Redux auth slice was completely reset to its initial guest state on every boot.

The culprit was a misunderstanding of how redux-persist handles store migrations.

The mechanism: the missing version check

When persistReducer boots, it reads the serialized state string from disk, deserializes it, and invokes config.migrate.

Crucially, the check that verifies whether the stored schema version is actually older than the current version does not exist inside persistReducer. That logic lives exclusively inside the createMigrate wrapper utility:

// The dangerous anti-pattern: a bare migration function
const persistConfig = {
  key: 'root',
  storage: AsyncStorage,
  version: 2,
  // BUG: This function runs on EVERY single application launch!
  migrate: (state) => dropOldAuth(state),
};

When you pass a bare function directly to config.migrate, redux-persist executes it on boot one, boot two, boot three, and every subsequent startup. In Liana, our migration was designed to drop the legacy v1 auth slice because tokens had moved to the Keychain. Because the migration was unversioned, it dropped the active customer session on every reload.

The delete operator rule

When updating persisted schemas, how you remove obsolete keys matters just as much as when you run the migration.

The default reconciler in redux-persist (autoMergeLevel2) merges persisted slices into your store key by key using Object.keys.

If your migration sets an obsolete slice to undefined, that key still exists in the inbound object:

// Anti-pattern: setting keys to undefined
const dropV1Auth = (state) => {
  const rest = { ...state };
  rest.auth = undefined; // BUG: Key exists!
  return rest;
};

During rehydration, autoMergeLevel2 sees the auth key and copies undefined over your reducer default initial state. When your user interface renders, the first selector that expects state.auth.user crashes with an immediate type error.

You must explicitly use the delete operator:

import { createMigrate, type PersistedState } from 'redux-persist';

export const PERSIST_VERSION = 2;

const dropV1AuthAndGuest = (state: PersistedState): PersistedState => {
  // Obsolete keys must be deleted so the reconciler falls back to initialState
  const rest = { ...(state as unknown as Record<string, unknown>) };
  delete rest.auth;
  delete rest.guest;
  return rest as unknown as PersistedState;
};

// createMigrate ensures this executes ONLY when stored version < 2
export const migrate = createMigrate({
  2: dropV1AuthAndGuest,
});

Wrapping the migration map in createMigrate guarantees that once a device reaches version two, subsequent reboots skip the migration entirely and preserve the customer session.

Two distinct kinds of logout

State persistence bugs do not only occur during startup. They also cause severe data leaks when users sign out.

In mobile engineering, logout encompasses two completely separate architectural scenarios:

  1. Session expiry: An access token or refresh token expires, or the backend returns HTTP 401 Unauthorized. In this case, you clear the authentication token only. You deliberately preserve draft form inputs, cached catalog listings, and offline work. When the user logs back in, their unfinished work remains available.
  2. Deliberate user sign-out: The customer taps Sign Out in Settings. The next person using this physical device might be a guest, a coworker, or a family member. You must aggressively wipe all account-scoped state: server caches, persisted slices, and disk files.

The user state reset registry

When an application grows to dozens of feature stores (Zustand, Redux slices, custom cache singletons), managing logout by manually importing every store into your auth module creates circular dependencies.

In Podium, we introduced a centralized user state reset registry:

// shared/session/userStateReset.ts
type ResetCallback = () => void;

const resetRegistry = new Set<ResetCallback>();

export const registerUserStateReset = (fn: ResetCallback): void => {
  resetRegistry.add(fn);
};

export const resetUserState = (): void => {
  resetRegistry.forEach((fn) => fn());
};

Every store that holds account-scoped data registers its own cleanup callback once at module load:

// features/cart/cartStore.ts
import { registerUserStateReset } from '@shared/session/userStateReset';

export const useCartStore = create((set) => ({
  items: [],
  clear: () => set({ items: [] }),
}));

registerUserStateReset(() => {
  useCartStore.getState().clear();
});

The complete sign-out pipeline

When the user taps Sign Out, the authentication handler runs a comprehensive four-step purge:

export const handleSignOut = async (services: IAuthServices): Promise<void> => {
  try {
    // 1. Attempt server token revocation
    await services.apiRevokeToken();
  } catch (error) {
    // Revocation failure must not prevent local device cleanup
  } finally {
    // 2. Wipe secure token from Keychain / Keystore
    await services.clearAuthToken();

    // 3. Clear TanStack Query server cache
    services.queryClient.clear();

    // 4. Reset all registered client stores
    resetUserState();

    // 5. Purge persisted redux-persist disk state
    await services.persistor.purge();
  }
};

Executing local cleanup inside finally ensures that even if the customer is offline or the server fails to revoke the token, their personal data is cleanly wiped from the mobile device.

The zero-caller cleanup trap

Writing cleanup code is not enough: you must verify that it is actually executed.

In Liana, our team wrote a dedicated disk cache manager for offline catalog data. It included a clear helper:

export const offlineCache = {
  /** Wipe every scope and language — used on sign-out. */
  clearAll(): void {
    storage.clearAll();
  },
};

The method was documented, reviewed, and tested. Yet during an architectural audit months later, a repository search revealed that offlineCache.clearAll() had exactly zero callers. The function existed, but nobody had wired it into the logout hook. Cached customer profile data and catalog searches remained sitting in unencrypted disk storage after logout.

Always write an automated test that asserts every cleanup helper executes during sign-out.

Architectural limitations

Redux-persist migrations are strictly forward-only. If you push a bad migration that corrupts stored state or deletes required keys, rolling back the application bundle will not revert the persisted database on customer devices.

Always test migrations against real production payloads from previous releases before deploying.

Verification steps

To verify store rehydration and logout safety in your own application:

  1. Sign into an account on a development build.
  2. Terminate the application process completely using the OS task switcher or CLI.
  3. Relaunch the app and confirm that the user remains signed in on launch two and launch three.
  4. Verify that obsolete keys are removed from AsyncStorage without crashing selector initial values.
  5. Populate local state (cart items, draft address forms, cached queries).
  6. Trigger an explicit sign-out and inspect disk storage: verify that persistor.purge(), queryClient.clear(), and all registered store resets executed.
  7. Sign in with a second distinct account and verify that zero state leaks from the first account.

Series roadmap

← All posts

Comments

No account needed.

  1. Loading comments…