A bug report that took us a while to believe: the app would occasionally decide the user was logged out of a site they were definitely logged into, and skip an auto-reply because of it.
Not consistently. Not reproducibly. More often when the user had several searches on the same site — which turned out to be the entire clue.
What the check does
To know whether a user is still logged into a rental site, we open their persistent browser profile and read its cookie store. That means launching a headless Chromium against a profile directory.
And Chromium enforces a lock: one process may hold a profile directory at a time. The second process to ask does not wait politely. It fails to read the profile — and a failed cookie read looks exactly like an empty cookie jar, which looks exactly like a logged-out user.
So two searches on the same host, checked concurrently at app start, produce one correct answer and one confident, wrong "logged out".
Why a wrong "no" was expensive
// Checks already running, keyed by hostname. Two callers asking about the same
// host at the same time would otherwise both try to open its browser profile,
// and Chromium only lets one process hold a profile at a time — the loser reads
// nothing and reports "logged out". Auto-reply refuses to reply without a
// login, so a wrong "logged out" is a reply that never happens: they share.
const _loginStatusInFlight = new Map<string, Promise<boolean>>();
The last sentence is why this was worth fixing properly. The auto-reply engine declines to send if it believes the session is gone — correctly, because submitting a contact form while logged out produces either an error or, worse, an anonymous message the landlord cannot reply to. So a spurious "logged out" is not a cosmetic glitch. It is the user silently not getting the thing they paid for, at the exact moment it mattered.
Three fixes, in order of subtlety
1. Cache the answer
const _loginStatusCache = new Map<string, { loggedIn: boolean; at: number }>();
const LOGIN_STATUS_TTL_MS = 60_000;
Launching a browser to read a cookie jar is expensive, and we were doing it on every app open and every window reload, per search. A one-minute TTL removes almost all of the calls.
This alone reduces the race but does not remove it: on a cold start the cache is empty and every caller misses at once. Caching makes a thundering herd rarer, never impossible. If the herd is a correctness bug rather than a performance one, a cache is not the fix.
2. Share the in-flight promise
The actual fix. Before starting a check, look for a check already running for that hostname and await that promise instead:
const existing = _loginStatusInFlight.get(hostname);
if (existing) return existing;
Store the promise, not the result. Everyone who asks during the window gets the same one browser launch and the same answer. This is request coalescing, and it is the right pattern any time the underlying resource is exclusive — profile locks, file locks, a single-connection device, an upstream API with a concurrency limit of one.
The key detail is that the map is keyed by hostname, so two hosts still run concurrently. Coalescing by resource, not globally.
3. A generation counter, for the stale-write problem
// Bumped whenever a host's session changes. A check that started before the
// change must not write its now-stale answer into the cache.
const _loginStatusGeneration = new Map<string, number>();
function invalidateLoginStatus(hostname: string): void {
_loginStatusCache.delete(hostname);
_loginStatusInFlight.delete(hostname);
_loginStatusGeneration.set(hostname, (_loginStatusGeneration.get(hostname) ?? 0) + 1);
}
This is the one people leave out. Picture the sequence: a check starts, and while the browser is launching, the user logs out. Invalidation clears the cache. Then the in-flight check finishes — with the answer that was true when it started — and writes loggedIn: true back into the freshly cleared cache.
You have just resurrected stale state into an invalidated cache, and it will sit there for the full TTL.
Clearing the cache is not enough, because the danger is a writer already in the air. Snapshot the generation when the check starts, compare before writing, and discard the result if it changed. Any read-through cache with invalidation needs this; most do not have it, and the resulting bugs get filed as "sometimes it shows the old value" and closed as unreproducible.
The shape of all three
Cache, coalesce, and version. Cache for cost, coalesce for correctness under concurrency, version for correctness under mutation. The first is the one everybody writes and the only one that is purely an optimisation.
Where this runs
The login-status check lives in the desktop app, which keeps a persistent browser profile per site so your sessions survive restarts — notifio.app to install it and log into a site inside the app. Log out in the middle of an active poll cycle if you want to watch the invalidation path do its job; you should get exactly one "login required" notice, not one per search.