Users could exploit a race condition by:
- Voting on a confession in the "posts" tab
- Quickly switching to the "hottest" tab while the vote is still processing
- Voting again on the same confession
- Result: Vote count appears to increase twice on the client side (e.g., 5 → 7)
This happened because each tab maintained separate state for confessions, and optimistic updates weren't synchronized across tabs.
- Separate State: Each tab (posts, hottest, saved, my-posts) had its own
useInfiniteConfessionshook instance - Optimistic Updates: Each hook applied optimistic updates independently
- No Synchronization: No mechanism to prevent duplicate votes across different tab states
- Race Condition: Users could trigger multiple votes before the first API call completed
Created a global state manager that tracks votes across all components:
// Global state to track votes across all components
const globalVoteState = new Map<string, VoteState>();
const pendingVotes = new Set<string>(); // Track confessionId + voteType combinations
// Subscription system for real-time updates
type VoteStateListener = (confessionId: string, voteState: VoteState | null) => void;
const voteStateListeners = new Set<VoteStateListener>();Key Features:
- Duplicate Prevention: Tracks pending votes to prevent multiple simultaneous votes
- Cross-Tab Synchronization: All tabs share the same vote state
- Real-time Updates: Subscription system for immediate vote state changes
- Optimistic Updates: Maintains consistent optimistic updates across all components
- Error Handling: Reverts optimistic updates on API failures
Modified all vote functions to use the global state:
// Each hook managed its own state independently
setConfessions(prev => prev.map(confession => {
if (confession.id === confessionId) {
return {
...confession,
believeCount: confession.believeCount + believeChange,
doubtCount: confession.doubtCount + doubtChange,
userVote: newUserVote,
};
}
return confession;
}));// Check if vote is already pending
if (isVotePending(confessionId, voteType)) {
return; // Vote already in progress, ignore this click
}
// Update global vote state (prevents duplicate votes across tabs)
const stateUpdated = updateVoteState(
confessionId,
voteType,
action,
currentConfession.believeCount,
currentConfession.doubtCount,
currentUserVote
);
if (!stateUpdated) {
return; // Vote already in progress
}Added multiple layers of synchronization to ensure all components display consistent vote counts:
// Subscribe to vote state changes for real-time updates
useEffect(() => {
const unsubscribe = subscribeToVoteState((confessionId, voteState) => {
// Update the confession in saved confessions if it exists
setSavedConfessions(prev => prev.map(confession => {
if (confession.id === confessionId) {
if (voteState) {
return {
...confession,
believeCount: voteState.believeCount,
doubtCount: voteState.doubtCount,
userVote: voteState.userVote,
};
}
}
return confession;
}));
});
return unsubscribe;
}, [subscribeToVoteState]);// Sync vote states when tab becomes visible
useTabVisibility(syncVoteStates);// Sync vote states when switching to saved or my-posts tabs
if (tab.key === "saved" || tab.key === "my-posts") {
setTimeout(() => {
syncAllVoteStates();
}, 100);
}// Sync confessions with global vote state
const syncedConfessions = newConfessions.map((confession: Confession) => {
const globalState = getVoteState(confession.id);
if (globalState && globalState.lastUpdated > Date.now() - 30000) {
return {
...confession,
believeCount: globalState.believeCount,
doubtCount: globalState.doubtCount,
userVote: globalState.userVote,
};
}
return confession;
});-
src/hooks/useVoteState.ts(NEW)- Global vote state manager
- Prevents duplicate votes
- Manages optimistic updates
- Real-time subscription system
- Tab switching synchronization
-
src/hooks/useTabVisibility.ts(NEW)- Detects when tabs become visible/active
- Triggers vote state synchronization
-
src/hooks/useInfiniteConfessions.ts- Updated to use global vote state
- Added state synchronization
- Prevents race conditions
-
src/hooks/useSavedConfessions.ts- Updated to use global vote state
- Added real-time subscription
- Added tab visibility detection
- Added automatic vote state sync
-
src/hooks/useUserConfessions.ts- Updated to use global vote state
- Added real-time subscription
- Added tab visibility detection
- Added automatic vote state sync
- Added
voteOnConfessionfunction for my-posts tab
-
src/app/meetups/confessions/page.tsx- Updated to use correct vote functions for each tab
- Added
voteOnUserConfessionfor my-posts tab - Added tab switching synchronization trigger
- Prevents duplicate pending votes
- Calculates vote changes consistently
- Updates global state atomically
- Checks if a vote is already in progress
- Prevents multiple simultaneous votes
- Marks votes as completed or failed
- Cleans up pending vote tracking
- Handles error scenarios
- Subscribes to real-time vote state changes
- Notifies all listeners when vote state updates
- Enables immediate synchronization across tabs
- Forces synchronization of all vote states across all tabs
- Called when switching to saved/my-posts tabs
- Ensures immediate consistency after tab switches
- Detects when browser tab becomes visible/active
- Triggers vote state synchronization automatically
- Handles cases where users switch between browser tabs
Created test scripts to verify the fix:
scripts/test-vote-race-condition.ts: Tests rapid voting doesn't create duplicate votesscripts/test-all-tabs-vote-sync.ts: Tests all tabs (posts, hottest, saved, my-posts) are synchronizedscripts/test-realtime-vote-sync.ts: Tests real-time vote synchronization across all tabsscripts/test-tab-switching-sync.ts: Tests immediate synchronization when switching between tabs- Verifies only one vote exists per user per confession
- Confirms vote counts are consistent across all tabs
- Validates immediate updates when switching between tabs
- Tests tab visibility detection and synchronization
- Prevents Race Conditions: Users can't exploit tab switching to create duplicate votes
- Consistent UI: All tabs show the same vote counts
- Real-time Synchronization: Vote changes appear immediately across all tabs
- Better UX: No confusing vote count jumps or delays
- Data Integrity: Backend and frontend stay in sync
- Performance: Optimistic updates still work for instant feedback
The fix is transparent to users - no changes in behavior except:
- Before: Could create duplicate votes by switching tabs quickly
- After: Duplicate votes are prevented, consistent experience across tabs
The implementation includes:
- Console logging for vote state changes
- Error tracking for failed votes
- Pending vote tracking for debugging
Potential improvements:
- Real-time Updates: WebSocket updates for live vote synchronization
- Vote History: Track vote changes for analytics
- Rate Limiting: Additional client-side rate limiting
- Offline Support: Handle votes when offline