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 GwinG, 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 GwinG — a language-exchange messenger with live translation.
- Android: Google Play
- Windows: .zip or installer
- Linux: .deb or portable tarball
Top comments (22)
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.
the absence assertion is the right shape — a leak is a write that shouldn’t exist, and checking that A still works can pass while B is quietly poisoned.
the capture primitive is already pinned: account_scope_isolation_test switches the live segment between awaits and asserts the operation keeps its original account, and the text-cache/freshness stores are checked for cross-account visibility. what isn’t covered yet is the end-to-end fixture you describe — switch mid-fetch, then enumerate B’s namespace and assert nothing landed there.
the eviction + in-flight refresh case is handled in code: the rotated token is persisted under the account that started the refresh, and persistence is skipped if that account was removed, so it can’t resurrect an evicted slot. but you’re right that it deserves a pinned test instead of staying an implementation detail. both cases belong in the suite.
Both pins write themselves from the failure shapes. Mid-fetch: start a fetch under A, switch to B between the await and the write, let it land, then assert two directions - B's namespace is empty AND A's data is intact. Checking only the first can pass while the write clobbered A's slot instead. Eviction: start a refresh under A, evict A mid-flight, complete the refresh, then assert no persistence happened and no slot resurrected - and enumerate B too, because "skipped the write" and "wrote it to the wrong account" are the same test from the runner's seat unless you look in both places. The rotated-token path is exactly the one where "handled in code" rots quietly, since nothing failing looks wrong when the account is gone. Thanks for checking what's actually pinned instead of defending the list.
thx 4 attention I am sorry I have no time now to answer properly now, I do it later!!
life got in the way of the proper reply — sorry for the wait.
you're right about the two directions, and the current mid-fetch pin is weaker than it looks on the A side.
chat_initial_load_testnow covers both the foreground first load and the debounced prefetch: start under A, switch to B before the result lands, then enumerate both stores — B empty, and A empty because the late result is dropped instead of written into its captured slot. but A starts empty in that test, so "A's data intact" can pass by accident. seeding A with a row before the fetch is the missing half, and I'm not calling that direction pinned until it's in.eviction is still not pinned. the code drops the rotated token when the account slot is gone — persisting it would resurrect the eviction and undo the LRU guarantee — but that decision lives only in the refresh path, which is exactly the thing that rots quietly. the fixture you described (six accounts, activate F, evict A mid-refresh, let the rotation land, assert no write, no resurrected slot, and enumerate B) is on the list as its own test rather than folded into the switch test.
thanks for pushing on the difference between "skipped the write" and "wrote it somewhere else"; that's the assertion that makes the test worth having.
Worth the wait. The gap you caught in your own test is the instructive part: "A's data intact" over an empty store is a vacuous assertion - it passes no matter what the code does. The rule I'd pin next to it: every "X is unchanged" assertion needs a pre-state that would fail if the code touched it. Seed, then assert. And "the decision lives only in the refresh path, which is exactly the thing that rots quietly" is the reason the eviction fixture can't stay on the list - a guarantee that exists only as the absence of a write in one path is invisible to code review, which sees presence. The six-account test is what makes the negative guarantee ("no write, no resurrected slot") something that fails loudly the day someone persists that token for a plausible reason. "Skipped the write" vs "wrote it somewhere else" was your framing to keep - it is the difference between a guard and a hope.
seed then assert is the rule I'm keeping next to the test, and the concrete shape is a sentinel: write one row into A before the fetch starts, let the late result land, then assert A holds exactly that row and B is empty. that fails if the result overwrites A's slot or leaks into B — the two ways "A's data intact" can lie while the store is empty.
on eviction, agreed — it can't stay on a list. saved_accounts_test already pins plain 5-account eviction, and that passing primitive is exactly what makes the combined gap easy to miss: the LRU works, the refresh routing works, and nothing exercises the seam. the six-account fixture is the only thing that makes "no write, no resurrected slot" visible to a reviewer, since review sees presence and a missing write looks like nothing happened.
one thing the fixture should pin explicitly: there are two refresh paths and they resolve the owner differently — the public refreshAccessToken derives it from the request token's sub, the in-session refresh from the account captured when the call started. both check the slot before persisting, but the token-routed path is the one where the id comes from the token itself, so the test should drive that one with A's old token and return a rotation whose sub is A. otherwise "dropped the write" and "never found the owner" look identical from the runner's seat.
Driving the token-routed path with A's old token and a rotation whose sub is A is the right call. To keep "dropped the write" and "never found the owner" apart, give each outcome its own observable, such as a distinct return value or log event (slot_missing vs owner_unresolved), and assert on that as well as on the store. Then add a positive control: same token, same rotation, but A's slot still present, asserting the write does land. Without it, a bug that drops every token-routed rotation would pass the eviction test too.
that's the right split, and the positive control is the part that makes the pair honest. today the failure is only observable as an absent write: the public path returns the rotated pair or throws, and the skip is silent — no log, no distinct return. so slot_missing vs owner_unresolved needs a small observable before the fixture can assert it, either an extra field on the returned map or a result type; the callers only read the two token keys, so that's additive.
the positive control is cheaper and I'd land it first: same fixture, A's slot still present, assert the rotated token was written. without it the eviction test passes if the path stops persisting entirely, which is exactly the shape of a regression that looks like success. with it, the pair reads as "persists when it should, refuses when it shouldn't" instead of one absence standing in for both.
“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?
fair — sign-out/sign-in is where the billing identity bites hardest, and it's the same ordering rule: logIn the new identity before anything scoped reads it. thanks for the honest answer on the prefetch race; that's the useful kind of comment. on FCM, payload-side account id plus drop-on-mismatch is the shape I want — the payload just doesn't carry the recipient id yet, only the sender's.
Building on the push-notification point — the FCM token is a two-sided trap, and the nastier half lives on the device.
Server side is what you described: one token per app install, so on a switch the token has to move atomically from A to B. If re-registering under B isn't a single transactional detach-then-attach call, there's a window where A keeps getting pushes to a device that's now B's — or B's messages silently never arrive. Registering only at app start misses the problem; the re-map belongs inside the switch sequence, after validation and before publish, like everything else here.
The other half is the local handler. A push for A can arrive between the tap and the switch completing, so onMessage can fire with A's payload while B is the active segment. Unless the handler resolves the sender's account from the payload before touching any scoped store, A's message lands in B's database — or A's sender name shows up in B's drawer, the tap-a-chat-B-can't-see scenario above. Tag the payload with the account id and drop it on mismatch with the active epoch, the same way late async work gets dropped.
One more: dismiss A's pending notifications during teardown, not after. A notification posted two minutes before the switch stays in the drawer after B is active.
the mapping itself isn't detach-then-attach: register-token reassigns user_id on the existing row in a single UPDATE, so there's no half-moved row. the window is the client ordering. it does re-register on every session setup, including a switch, but session setup runs after the session is published — and it does a fleet lookup and a WS connect before the token POST. so between publish and that POST the device is B while the token still resolves to A. moving the re-map into the switch, before publish, is the fix. (the endpoint also returns success when the commit fails, so a stale mapping can survive silently.)
one correction on the handler: it doesn't write message rows. the background path marks the conversation as having advanced and posts a local notification; the foreground path only posts the notification. so a stale A payload under B leaves an activity marker in B's store and A's notification in the drawer, not A's message in B's database. the mismatch check is still the right shape, but the payload only carries the sender's user_id, not the recipient account, so there's nothing to compare until the server adds that field.
on dismissal, agreed — teardown cancels prefetchers and the socket but doesn't clear the drawer. but dismissing there only covers what already arrived: a push that lands in the gap between teardown and the token re-map will still post. so the three points are one ordering problem — re-map before publish, carry the recipient account in the payload, and sweep the drawer during teardown. the sweep without the other two just shortens the window.
Fair point — a single UPDATE means the mapping itself is atomic, so there's no half-moved row at the DB layer. The window I had in mind is exactly the ordering one you describe: publish lands while the token still resolves to A. Moving the token POST before publish (or gating publish on the new token's epoch) closes it without needing a lock.
yep — that was one of the three bugs the review pass caught. a prefetch that started under A finished after the switch and wrote into B’s message database. the fix is the pattern you describe: capture AccountScope.segment before the first await, bail if the live segment changed, and pass the captured segment into the cache write instead of letting it re-read the current account at completion time.
teardown also cancels queued prefetchers, and the upload outbox + chunked-upload dirs are per-account, so a send can’t resume under the new session. the FCM background isolate is the exception — it has no AccountScope, so it reads the persisted active account id. did you hit this in an isolate or in ordinary in-flight async work?
Neither, to be honest: our app has no account switching, so I haven't hit the prefetch race myself. The two points came from wiring sign-out and sign-in, where the billing identity (RevenueCat logIn/logOut) has to be part of the sequence, or the previous user's Pro status lingers.
On the FCM isolate reading, the persisted active ID, Samii's suggestion below, fits it well. If the payload carries the account ID, the isolate can compare it with the persisted one and drop the message on a mismatch, instead of assuming the persisted ID is right at that exact moment, which it may not be mid-switch.
And free for the first 1,000 users sounds like a good way to learn before deciding what's worth charging for.
free for first 1000 of users, this is a decision of team for now it is not project to make $. so we'll try keep super low - and in general it is possibly now
Makes sense: if the payload only names the sender, there's nothing to compare against until the server adds the recipient.
One thing to watch when you add it: on Android, a push that carries a notification block is shown by the system while the app is in the background, before any of your code runs. So drop-on-mismatch only works for data-only messages that your handler turns into a local notification. iOS behaves the same way for alert pushes unless they go through a notification service extension.
From what you describe, your background path already posts its own notification, so you may be fine. It's just easy for one server code path to keep sending a notification block and skip the check without anyone noticing.
Isolation is the right thing to focus on. One place that leaks often: background work. A sync for account A can finish after the switch and write into B's cache. Tag every job with its account id. Drop any result that doesn't match the active account. Did you run into it?
yep — that was one of the three bugs the review pass caught. a prefetch that started under A finished after the switch and wrote into B’s message database. the fix is the pattern you describe: capture AccountScope.segment before the first await, bail if the live segment changed, and pass the captured segment into the cache write instead of letting it re-read the current account at completion time.
teardown also cancels queued prefetchers, and the upload outbox + chunked-upload dirs are per-account, so a send can’t resume under the new session. the FCM background isolate is the exception — it has no AccountScope, so it reads the persisted active account id. did you hit this in an isolate or in ordinary in-flight async work?
Ordinary in-flight async work is the common case. A sync starts before the switch and finishes after it. Capturing the scope before the first await is the clean fix. On the FCM path, a switch can still happen between receipt and the write. If the payload carries the target account id, the isolate writes there directly. Then the active id does not matter.
agreed on ordinary in-flight work being the common case. on FCM: the payload's user_id is the sender, not the recipient account, so the isolate can't route by it today — it falls back to the persisted active id for the activity marker (the push itself doesn't write message rows). adding the recipient account id to the payload is the version that removes the race: write to the named account's namespace, or drop on mismatch, and the active id stops mattering. the server already knows the recipient, so it's a payload field, not a lookup.