A user signs up, lands on the home screen, finishes filling in their info, comes back — and then the user-survey modal appears, late. Sometimes it happens, sometimes it doesn’t. “Sometimes” is usually a race condition.
1. Diagnose
When the home screen mounts, useHomeInit fetches the user’s knowledge level asynchronously and decides whether to redirect to /user-survey. The original code looked like this:
useEffect(() => {
let mounted = true
const init = async () => {
try {
await fetchAuthQuery(KNOWLEDGE_LEVEL_QUERY_KEY, () =>
cheongyakRuleSetApi.getKnowledgeLevel(),
)
if (!mounted) return
// success — knowledge level exists
} catch (error) {
if (error instanceof UnauthorizedError) return
if (!mounted) return
Router.replace('/user-survey') // ← problem lives here
}
}
init()
return () => { mounted = false }
}, [])
It looks fine on the surface. The mounted flag protects against setState-after-unmount.
The problem is timing. If the user lands on home and quickly taps another CTA — say “Enter your info” — the home component unmounts and mounted flips to false. Then the getKnowledgeLevel() 404 response arrives, lands in the catch block, hits if (!mounted) return, and Router.replace('/user-survey') is silently dropped.
The downstream effect: the user-survey that should have appeared on first entry never fires. The user finishes their info-entry flow, comes back to home, useHomeInit runs again — and now user-survey shows up. From the user’s perspective, “I just finished entering my info, why is a survey suddenly popping?“
2. Reproduce
A timing-dependent bug needs help to reproduce reliably. I throttled the network to Slow 3G in Chrome DevTools and ran this sequence:
- Sign up and arrive at home.
- Before
getKnowledgeLevel()resolves, tap another CTA on the home screen quickly. Home unmounts. - Walk through the CTA flow (entering info, etc.).
- Return to home.
useHomeInit’s effect runs again, and this time user-survey appears.
Fast networks rarely showed it because the response arrived before the mounted guard ever mattered. So it almost never reproduced internally — it only surfaced sporadically in real user telemetry.
The root issue, distilled: an async decision needs to navigate, but the navigation is gated by a mounted flag whose real job is blocking setState. Two distinct side effects (state update, navigation) were sharing a single guard, and the navigation needed to keep happening even after the component was gone.
3. Fix
The cleanest solution was don’t render Home until the knowledge level decision is done. I introduced a ready state and rendered null in Home’s slot while it was unresolved.
// pages/index.tsx
export default function HomePage() {
const ready = useHomeInit()
return ready ? <Home /> : null
}
// useHomeInit
export function useHomeInit() {
const [ready, setReady] = useState(false)
useEffect(() => {
const cached = getAuthQueryData<{ level?: string | null }>(
KNOWLEDGE_LEVEL_QUERY_KEY,
)
if (cached?.level) {
setReady(true)
return
}
let mounted = true
const init = async () => {
try {
await fetchAuthQuery(KNOWLEDGE_LEVEL_QUERY_KEY, () =>
cheongyakRuleSetApi.getKnowledgeLevel(),
)
if (!mounted) return
amplitudeLogger.event('set_once_knowledge_level')
setReady(true)
} catch (error) {
if (error instanceof UnauthorizedError) {
if (mounted) setReady(true)
return
}
if (!mounted) return
Router.replace('/user-survey')
}
}
init()
return () => { mounted = false }
}, [])
return ready
}
Now Home doesn’t even mount until the knowledge level is resolved. Without Home rendered, there are no CTAs to tap, no way for the user to leave — the window in which the race could happen no longer exists. Once the resolve completes, users who need redirection go straight to /user-survey, and everyone else gets Home immediately.
If a cached value exists, ready flips to true synchronously, so on second-time entry there’s no flicker. The UnauthorizedError path also sets ready so unauthenticated users don’t get stuck on a blank page.
4. Prevent recurrence
A handful of habits crystallized after this episode.
First, when a flow has the shape “async decision → conditional redirect,” default to withhold rendering until the decision lands. The mounted guard works for blocking setState, but it doesn’t work for “the navigation that was supposed to happen vanished silently.” User-visible side effects need to be uncoupled from mounted.
Second, when a race is suspected, draw a state diagram. (loading → ready | redirect-pending | unmounted) is enough — five minutes on paper makes “the gap state where neither ready nor redirect is true” visible immediately. That gap is where this bug lived.
Third, simulate every point a user could leave the screen. Throttling the network and clicking around aggressively flushes out most race conditions. Chrome DevTools throttling is the cheapest tool for timing bugs — toggle it on once a week during development and you’ll catch problems weeks before users do.
Timing bugs don’t reveal themselves through code reading. They only show up through behavior, and they show up unevenly. That’s why prevention matters more than detection here. Whenever I now see “async work + the user might leave at any moment” in the same place, I ask: what happens if the user leaves before this resolves? It’s a small question that saves a lot of pain.