Most messenger accounts are single-account by design: you sign in, the session
lives in one token slot, and every local cache is global. That works until a
real user needs two accounts on the same device — a personal profile and a
work one, or two language-learning identities.
In tonGues, a Flutter language-exchange messenger, we added saved
accounts: the app remembers up to five accounts and switches between them in
one tap, without a browser or a Google round trip. The interesting part was not
the picker. It was making "switch account" safe — the new session has to start
clean, and none of the previous account's data may leak into it.
This post covers the UX we landed on, the session lifecycle, and the isolation
patterns that made fast switching reliable.
What the user sees
Saved accounts are listed on the login screen above the sign-in button, and in
an account switcher reachable from three places:
- long-press the profile avatar in the navbar (right-click on desktop);
-
Alt+0(Ctrl+0if the user chose Ctrl as the main modifier); - More → Switch account, next to Log out.
The switcher can switch to a saved account, add another one, remove a single
account, or sign out of everything. After a successful switch the app opens a
fresh home shell. That detail matters: the tab pages live in an IndexedStack,
so reusing the shell would keep the previous account's friends, chats and
search results mounted — and visible for a frame. Rebuilding the shell disposes
them instead.
Up to five accounts are remembered, most-recently-used first. Adding a sixth
evicts the least recently used account that is not the one being activated, and
removes its local data.
Validate before you tear down
The tempting implementation is: read the target's token, drop the current
session, then validate. It is also the one that hurts users. A dropped Wi-Fi
connection at the wrong moment leaves them signed out of both accounts.
The rule we use is the reverse: validate the target completely, then teardown,
then publish.
saved account tapped
│
├─ access token still valid? ──no──► refresh the session
│ │
│ └─ rejected? → remove entry, show error
▼
confirm the identity with the backend
│
├─ network failure → keep the current session untouched
└─ valid → tear down → publish → reconnect
A network failure leaves the user exactly where they were. Only a definitive
rejection removes the saved entry and asks for a fresh sign-in.
The teardown itself is deliberately boring: end any active call, stop presence
polling, dispose the WebSocket, cancel background prefetchers, reset cached
encryption identity state, then clear in-memory tokens and notifiers. The
target's persisted data is not deleted by a switch.
One session at a time
Two switches interleaving is a surprisingly easy bug to ship. We learned the
hard way that a "switch in progress" flag must be claimed before the first
network await, not after validation. Validation can take several round trips;
a second tap during that window otherwise passes the guard too.
We also stamp each session with an epoch. Late async work compares its captured
epoch with the current one and drops the result if the session moved on. That
covers the usual suspects — a profile fetch that finishes after a switch, a
token refresh that belongs to the previous account, a background cache refresh
that was already in flight.
There is a second half to the token-refresh case: the rotated token still
belongs to the account that requested it. We read the account id from the
refresh token itself and persist the result in that account's slot, never in
"whichever account is active now." A request that started under account A may
finish under account B, but it must not write into B.
Scoping on-device data by account
This is where most of the work lives. A messenger accumulates a lot of local
state: messages, translations, pinned messages, chat tabs, drafts, encryption
keys, the upload outbox, friends. If any of it is keyed globally, the second
account sees the first account's data.
Every store now includes the account id in its key or path:
| Store | Scoped form |
|---|---|
| Message cache | one database per account |
| Translations / transcriptions | key suffix per account |
| Pinned messages | account + conversation |
| Conversation freshness markers | account + conversation |
| Chat tabs | account |
| Encryption keys | account prefix |
| Drafts and upload outbox | already per account |
The fallback segment is _shared, used only before a session exists. In normal
use there is always an account.
Encryption keys are the critical case. Sharing an identity between accounts
would present the same encryption identity to peers for two different users and
corrupt ratchet state on both sides. The cached identity is dropped on a switch,
and the next key operation reloads the target account's keys.
Keeping async work on its account
Keying the stores by account is necessary but not sufficient. The subtle bug is
when the account is resolved. A cache write often happens after network
awaits:
final account = AccountScope.segment;
final fetched = await api.fetchMessages(conversationId);
if (AccountScope.segment != account) return; // the session moved on
await MessageCacheService.save(conversationId, fetched, segment: account);
Three details make this reliable:
- The account is captured once, before the first await.
- The result is dropped if the active account changed in the meantime.
- The cache API accepts the captured segment instead of re-reading the global state at write time.
For encryption operations we go one step further and freeze the account for the
whole operation, across every nested await. Dart zones are a neat fit: the
operation runs in a child zone that carries the account, and key-store reads and
writes resolve that captured value instead of the live session.
static Future<T> capture<T>(Future<T> Function() body) {
final captured = segment;
return runZoned(body, zoneValues: {_operation: captured});
}
static String get segment =>
(Zone.current[_operation] as String?) ?? _userId ?? '_shared';
This is the difference between "the key store is usually scoped" and "an
encrypt that started before a switch can never write into the next account."
Migrating the single-account past
Existing installs had exactly one session: un-prefixed tokens, one message
database, one encryption identity, one tab layout. Updating must not log those
users out or orphan their encrypted history.
The migration runs only when the app has proven which user the legacy session
belongs to. It copies the tokens into that user's scoped slots, moves the
encryption keys and message cache into the same namespace, and then deletes the
legacy copies.
The important safety rule: a legacy layout is never claimed by "whichever
account loads first." If the old session cannot be restored at launch and the
user signs in manually, the app still migrates the legacy data to its real
owner and clears the old token slots — so a later launch can never silently
restore the wrong identity.
Three bugs a review pass caught
The tests were green and the analyzer was clean, but a behavioural review still
found three isolation defects worth sharing:
- A partial secure-store read looked like a complete session. One token key was present and the other was not, so the read path skipped the fallback and returned a session with no refresh token. Fix: complete only the missing half from the fallback, and add a test for the mixed state.
- Cache writes resolved the account at completion time. A prefetch that started under account A could finish after a switch and write into account B's database. Fix: capture the account at the start and pass it through.
- A legacy layout was claimed by the first account to load. That exposed the previous user's tab names and chosen contacts. Fix: migrate legacy data only on the proven legacy path.
None of these are compiler errors. They are the class of bug that only shows up
when a real user switches accounts at an awkward moment.
Lessons
- Validate the destination before ending the current session. Users forgive a slow switch; they do not forgive being signed out of both accounts.
- Capture ambient state at the start of async work. "Current account" is ambient state, and it can change under you.
- Make the account an explicit parameter at the storage boundary. It turns a silent cross-account write into a compile-time argument.
- Treat legacy data as owned by a proven user. Never let a random first load claim it.
- Test the mixed and interrupted states, not just the happy path: partial storage, a switch mid-fetch, a switch while a key operation is running.
FAQ
Does this work on desktop?
Yes. Long-press or right-click the avatar, or press Alt+0. The same switcher
is available from the More tab.
Do inactive accounts receive notifications?
No. Push notifications follow the active account on that device. Switching back
restores its session and its pending sends.
What happens to local data when I log out?
A plain log-out removes the current account's tokens and its entry from the
picker, but keeps its namespaced local data so signing back in later restores the
encrypted history. Removing an account, or signing out of all accounts, deletes
that account's local data as well.
How long does a saved account stay usable?
As long as its refresh session is valid. If it expires, the picker asks you to
sign in again for that account instead of failing silently.
Why only five accounts?
It covers the realistic shared-device and personal/work cases while keeping the
picker and the local storage footprint small. The limit is a constant, not a
backend restriction.
Get tonGues — a language-exchange messenger with live translation.
- Android: Google Play
- Windows: .zip or installer
- Linux: .deb or portable tarball
Top comments (2)
The captured-segment rule is the heart of this, and the test that proves it has to assert an absence, which is the part most suites skip. Fetch under A, switch to B mid-flight, then don't just check A's cache got the write - enumerate B's namespace and assert nothing landed there. A leak is a write that shouldn't exist, and "A still works" can pass while B is quietly poisoned. Same for the epoch stamp: the late profile fetch finishing under B is only a real test if B's UI and stores are inspected for A's data, not merely left unrefreshed. One more fixture for the eviction path, because it combines your two hardest mechanisms: six saved accounts, account F being activated, and the true LRU has a token refresh in flight at the moment it's evicted. The rotated token arrives after eviction. Assert it doesn't resurrect the evicted slot - the account id is read from the refresh token itself, which is exactly the information that would write into a deleted namespace. That's the case where "persist into the requesting account's slot" and "the slot no longer exists" collide, and whichever behavior you choose (drop silently, queue for re-add) deserves a pinned test rather than an accident.
“Validate the target, then tear down, then publish” is the right order, and capturing the account before the first await is a detail most apps miss.
Two more places where account A can leak into account B that aren’t local storage:
1. Push notifications. If the device’s FCM token is registered on the server under account A, A’s messages keep arriving while B is active, and tapping one opens a chat B can’t see. On a switch, the token has to move to the new account on the server (or be kept per account, with notifications routed by account id).
2. Purchases. With a billing SDK like RevenueCat, the customer identity is cached on the device. Without an explicit logIn with the new user id on every switch, one account’s Pro status can show up on the other until the next refresh.
Does tonGues have paid features tied to the account, or is everything free for now?