- Add IV validation and key sanitization for encryption - Add stale key repair for media decryption failures - Block READ_MEDIA_IMAGES/VIDEO permissions (use photo picker) - Prevent double-tap crashes in media picker - Clear media cache on logout - Persist versionCode/buildNumber in app.json - Bump Android targetSdk to 35
14 KiB
Frontend-Backend Communication Optimization Plan
This document outlines identified chattiness issues in the Sauci mobile app and provides a prioritized implementation plan for optimizations.
Executive Summary
Estimated Extra Traffic: ~40-50% of current API calls are redundant or suboptimal
Key Problem Areas:
- Partner profile refreshed on every message send
- Match details fetched from DB instead of using cached store data
- Redundant polling alongside real-time subscriptions
- N+1 query patterns in match/response fetching
- Missing caching strategies across stores
High Priority Tasks
Task 1: Cache Partner Public Key with TTL
Impact: Eliminates 1 database query per message sent
Current Behavior:
useEncryptedSend.ts:84-85callsrefreshPartner()on every text messageuseMediaUpload.ts:80-81callsrefreshPartner()on every media upload- No caching - always hits database
Files to Modify:
apps/mobile/src/store/authStore.tsapps/mobile/src/hooks/useEncryptedSend.tsapps/mobile/src/features/chat/hooks/useMediaUpload.ts
Implementation:
// In authStore.ts - add to state
partnerLastFetched: number | null;
// In refreshPartner action
refreshPartner: async () => {
const now = Date.now();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const { partner, partnerLastFetched } = get();
// Return early if cache is fresh and partner has public key
if (partner?.public_key_jwk &&
partnerLastFetched &&
now - partnerLastFetched < CACHE_TTL) {
return;
}
// ... existing fetch logic
set({ partner: data, partnerLastFetched: now });
}
In hooks - add force refresh option:
// Only force refresh if encryption fails due to key mismatch
const sendEncrypted = async (content: string, forceKeyRefresh = false) => {
if (forceKeyRefresh) {
await refreshPartner();
}
// ... rest of logic
}
Acceptance Criteria:
- Partner profile cached for 5 minutes
- Cache bypassed if
public_key_jwkis null - Force refresh available for encryption failures
- No regression in E2EE functionality
Task 2: Use Store Data for Message Notifications
Impact: Eliminates 1 database query per incoming message
Current Behavior:
_layout.tsx:462-466fetches match details from DB for every incoming message- Match data already exists in
matchStore
Files to Modify:
apps/mobile/app/(app)/_layout.tsx
Implementation:
// Replace DB query with store lookup
const handleNewMessage = async (newMessage: Message) => {
// Try to get match from store first
const { matches } = useMatchStore.getState();
const existingMatch = matches.find(m => m.id === newMessage.match_id);
if (existingMatch) {
// Use cached data
showNotificationToast(existingMatch.question.text, newMessage);
} else {
// Fallback: fetch from DB only if not in store (rare edge case)
const { data: match } = await supabase
.from("matches")
.select("id, question:questions(text)")
.eq("id", newMessage.match_id)
.single();
if (match) {
showNotificationToast(match.question.text, newMessage);
// Also refresh matches store since we're missing data
fetchMatches();
}
}
};
Acceptance Criteria:
- Message notifications use store data when available
- Fallback to DB query only when match not in store
- Store refreshed if match is missing (data consistency)
Task 3: Remove Redundant Polling in Pairing Screen
Impact: Eliminates polling every 5 seconds when waiting for partner
Current Behavior:
pairing.tsx:39-45pollsfetchCouple()every 5 secondspairing.tsx:47-71also subscribes to real-time profile updates- Both doing the same job - redundant
Files to Modify:
apps/mobile/app/(app)/pairing.tsx
Implementation:
// DELETE the polling interval (lines 39-45)
// KEEP only the real-time subscription
useEffect(() => {
if (!couple || partner) return;
// Real-time subscription handles partner joining
const subscription = supabase
.channel(`couple-${couple.id}`)
.on(
"postgres_changes",
{
event: "UPDATE",
schema: "public",
table: "profiles",
filter: `couple_id=eq.${couple.id}`,
},
(payload) => {
if (payload.new.id !== user?.id) {
fetchCouple(); // Partner joined
}
}
)
.subscribe();
return () => {
subscription.unsubscribe();
};
}, [couple, partner, user?.id, fetchCouple]);
Acceptance Criteria:
- Polling removed
- Real-time subscription still detects partner joining
- No increase in time to detect partner
Medium Priority Tasks
Task 4: Optimize Match Responses Query (N+1 Fix)
Impact: Reduces 2 queries to 1, eliminates client-side filtering
Current Behavior:
matchStore.ts:23-29fetches all matchesmatchStore.ts:35-38fetches ALL responses separately- Client-side filtering to match responses to questions
Files to Modify:
apps/mobile/src/store/matchStore.ts
Implementation:
// Option A: Join responses in single query
const fetchMatches = async () => {
const { data: matches } = await supabase
.from("matches")
.select(`
*,
question:questions(*),
user_response:responses!inner(
id, answer, user_id
)
`)
.eq("couple_id", coupleId)
.in("responses.user_id", [userId, partnerId])
.order("created_at", { ascending: false });
// Process matches with embedded responses
const processedMatches = matches?.map(match => ({
...match,
userAnswer: match.user_response.find(r => r.user_id === userId)?.answer,
partnerAnswer: match.user_response.find(r => r.user_id === partnerId)?.answer,
}));
set({ matches: processedMatches });
};
Alternative - Database View:
-- Create view that joins match with responses
CREATE VIEW match_with_responses AS
SELECT
m.*,
q.text as question_text,
q.intensity,
ur.answer as user_answer,
pr.answer as partner_answer
FROM matches m
JOIN questions q ON m.question_id = q.id
LEFT JOIN responses ur ON m.question_id = ur.question_id AND ur.user_id = m.user1_id
LEFT JOIN responses pr ON m.question_id = pr.question_id AND pr.user_id = m.user2_id;
Acceptance Criteria:
- Single query fetches matches with responses
- No client-side filtering of responses
- Match list still displays correctly
Task 5: Fix Mark All As Seen Trigger
Impact: Eliminates unnecessary DB updates on every render
Current Behavior:
matches.tsx:66-69callsmarkAllAsSeen()whenevermatches.lengthchanges- Triggers even when navigating back to screen with same matches
Files to Modify:
apps/mobile/app/(app)/matches.tsx
Implementation:
// Move markAllAsSeen to useFocusEffect, run once per focus
useFocusEffect(
useCallback(() => {
fetchMatches();
// Mark as seen after fetch completes
return () => {
// Optionally mark as seen on blur instead
};
}, [])
);
// Separate effect for marking seen - runs once after initial fetch
const [hasMarkedSeen, setHasMarkedSeen] = useState(false);
useEffect(() => {
if (matches.length > 0 && !hasMarkedSeen) {
markAllAsSeen();
setHasMarkedSeen(true);
}
}, [matches.length, hasMarkedSeen]);
// Reset on screen blur
useFocusEffect(
useCallback(() => {
return () => setHasMarkedSeen(false);
}, [])
);
Acceptance Criteria:
markAllAsSeen()only called once per screen visit- New matches still get marked as seen
- No regression in notification badge behavior
Task 6: Batch Message Status Updates
Impact: Reduces 2 queries to 1 when opening chat
Current Behavior:
useMessageSubscription.ts:125-143makes separate updates for:- Messages to mark as read
- Messages to mark as delivered
Files to Modify:
apps/mobile/src/hooks/useMessageSubscription.ts
Implementation:
// Combine into single update operation
const markMessagesAsReadAndDelivered = async (messages: Message[]) => {
const now = new Date().toISOString();
const userId = useAuthStore.getState().user?.id;
// Find messages that need updating
const messagesToUpdate = messages.filter(
m => m.user_id !== userId && (!m.read_at || !m.delivered_at)
);
if (messagesToUpdate.length === 0) return;
const messageIds = messagesToUpdate.map(m => m.id);
// Single update query
await supabase
.from("messages")
.update({
delivered_at: now,
read_at: now
})
.in("id", messageIds)
.is("read_at", null); // Only update unread
};
Acceptance Criteria:
- Single query updates both delivered_at and read_at
- Read receipts still work correctly
- No regression in message status display
Task 7: Debounce Answer Gap Check
Impact: Reduces RPC calls during rapid swiping
Current Behavior:
swipe.tsx:171checks answer gap on mountswipe.tsx:228checks again after EVERY swipe response
Files to Modify:
apps/mobile/app/(app)/swipe.tsx
Implementation:
// Add debounced version
const debouncedCheckAnswerGap = useMemo(
() => debounce(checkAnswerGap, 2000), // 2 second debounce
[checkAnswerGap]
);
// On response submit
const handleResponse = async (answer: Answer) => {
await submitResponse(questionId, answer);
// Debounced check - won't fire if user swipes again within 2s
debouncedCheckAnswerGap();
};
// Cleanup on unmount
useEffect(() => {
return () => debouncedCheckAnswerGap.cancel();
}, []);
Acceptance Criteria:
- Answer gap only checked after swiping pauses
- Still checks on initial mount
- Gap modal still appears when threshold exceeded
Low Priority Tasks
Task 8: Add Stale Time to Packs Store
Impact: Prevents unnecessary refetches of static data
Files to Modify:
apps/mobile/src/store/packsStore.ts
Implementation:
// Add to store state
packsLastFetched: number | null;
// In fetchPacks
fetchPacks: async () => {
const STALE_TIME = 10 * 60 * 1000; // 10 minutes
const { packsLastFetched, packs } = get();
if (packs.length > 0 &&
packsLastFetched &&
Date.now() - packsLastFetched < STALE_TIME) {
return; // Use cached data
}
// ... existing fetch logic
set({ packs: data, packsLastFetched: Date.now() });
};
// Add force refresh for when user explicitly requests
fetchPacksForce: async () => {
set({ packsLastFetched: null });
return get().fetchPacks();
};
Task 9: Persist Matches to AsyncStorage
Impact: Faster initial load, offline capability
Files to Modify:
apps/mobile/src/store/matchStore.ts
Implementation:
import AsyncStorage from "@react-native-async-storage/async-storage";
import { persist } from "zustand/middleware";
export const useMatchStore = create<MatchStore>()(
persist(
(set, get) => ({
// ... existing store
}),
{
name: "match-storage",
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
matches: state.matches,
// Don't persist loading states
}),
}
)
);
Task 10: Combine Couple + Partner Fetch
Impact: Reduces 2 queries to 1 in fetchCouple
Current Behavior:
authStore.ts:137-149fetches couple, then partner separately
Files to Modify:
apps/mobile/src/store/authStore.ts
Implementation:
// Single query with join
const { data: coupleData } = await supabase
.from("couples")
.select(`
*,
profiles!inner(*)
`)
.eq("id", user.couple_id)
.single();
const partner = coupleData.profiles.find(p => p.id !== user.id);
set({ couple: coupleData, partner });
Implementation Order
| Priority | Task | Effort | Impact |
|---|---|---|---|
| 1 | Task 1: Cache partner key | Low | High |
| 2 | Task 2: Store-based notifications | Low | High |
| 3 | Task 3: Remove polling | Low | Medium |
| 4 | Task 5: Fix mark as seen | Low | Medium |
| 5 | Task 6: Batch message updates | Low | Low |
| 6 | Task 7: Debounce gap check | Low | Low |
| 7 | Task 4: N+1 fix | Medium | Medium |
| 8 | Task 8: Packs stale time | Low | Low |
| 9 | Task 10: Combine couple fetch | Low | Low |
| 10 | Task 9: Persist matches | Medium | Low |
Testing Checklist
After implementing optimizations:
- E2EE messaging still works (send/receive encrypted messages)
- Partner joining couple is detected in real-time
- Match notifications display correctly
- New matches appear and are marked as seen
- Message read receipts work
- Answer gap modal appears when appropriate
- Packs load and display correctly
- No increase in error rates (monitor logs)
Metrics to Track
Before/after comparison:
- API calls per session - Target: 40% reduction
- Time to interactive on home screen - Target: <500ms
- Message send latency - Target: No increase
- Battery usage - Target: 20% reduction in background activity
Notes
- All caching should have invalidation strategies
- Consider adding React Query or TanStack Query for more sophisticated caching
- Real-time subscriptions are preferred over polling
- Database views can simplify complex queries but add maintenance overhead