Files
Sauci/docs/frontend-optimization-plan.md
T
Jamie Brunton 348ef221db Improve E2EE robustness and Android permissions
- 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
2026-01-02 21:36:26 +00:00

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-85 calls refreshPartner() on every text message
  • useMediaUpload.ts:80-81 calls refreshPartner() on every media upload
  • No caching - always hits database

Files to Modify:

  • apps/mobile/src/store/authStore.ts
  • apps/mobile/src/hooks/useEncryptedSend.ts
  • apps/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_jwk is 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-466 fetches 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-45 polls fetchCouple() every 5 seconds
  • pairing.tsx:47-71 also 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-29 fetches all matches
  • matchStore.ts:35-38 fetches 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-69 calls markAllAsSeen() whenever matches.length changes
  • 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-143 makes separate updates for:
    1. Messages to mark as read
    2. 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:171 checks answer gap on mount
  • swipe.tsx:228 checks 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-149 fetches 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:

  1. API calls per session - Target: 40% reduction
  2. Time to interactive on home screen - Target: <500ms
  3. Message send latency - Target: No increase
  4. 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