Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/architecture/authenticated-feed-detail-caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Authenticated Feed Detail Caching Architecture

## Context & Problem Statement
Prior to this redesign, authenticated feed detail requests were cached on the Next.js server using `unstable_cache` keyed on `userId` (`feed-complete-${feedDataType}-${feedId}-${userId}`) with a 10-minute TTL:
- Storing per-user caches on the server consumed substantial memory/disk space on Vercel and serverless instances.
- The underlying public feed metadata (`feed`, `datasets`, `routes`, `reliability`, `quality`) is mostly identical across users of the same role.
- Redundant server-side copies provided zero benefit to other users.

## Evaluated Approaches

### Option 1: Client-Side SWR Caching
- **Design:** Keep the server stateless with respect to individual users. Serve initial feed payloads via React `cache()` request deduplication, then leverage client-side SWR (`useFeedDetailCache`) in the browser.
- **Benefits:**
- Browser memory caches the feed details per-user with `stale-while-revalidate`.
- Zero server data cache footprint for individual users.
- Can be invalidated immediately on mutations (e.g. subscribing, updating feed metadata) or through broadcast events.
- **Implementation:** Added `useFeedDetailCache` hook in `src/app/screens/Feed/hooks/useFeedDetailCache.ts`.

### Option 2: Role-Based Server Caching
- **Design:** Instead of partitioning server cache keys by `userId`, partition by user role:
- `guest`: Unauthenticated users (long ISR cache, shared across all anonymous visitors).
- `authenticated`: Verified users with standard access.
- `admin`: Internal MobilityData team with admin bypass and draft access.
- **Benefits:**
- Dramatically collapses the cache space from `O(users * feeds)` to `O(roles * feeds)`.
- At most 2 cache entries exist per feed on the server for all logged-in users combined.
- Allows cache invalidation via `/api/revalidate` with `tags: ['feed-${feedId}', 'role-${role}']`.
- **Implementation:** Updated `fetchCompleteFeedData` in `src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data.ts`.

## Integrated Solution
We implement the best of both approaches:
1. **Server Side:** Key `unstable_cache` by `userRole` (`admin` vs `authenticated`) instead of `userId`.
2. **Request Deduplication:** Rely on React's `cache()` to prevent duplicate upstream calls across layout, page, and `generateMetadata`.
3. **Client Side:** Use `useFeedDetailCache` with SWR for browser-level caching, smooth client navigation, and instant mutation updates.
4. **Invalidation:** Keep tags `feed-${feedId}` and `role-${userRole}` aligned with `/api/revalidate`.
25 changes: 14 additions & 11 deletions src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getSSRAccessToken,
getUserContextJwtFromCookie,
getCurrentUserFromCookie,
isMobilityDatabaseAdmin,
} from '../../../../../utils/auth-server';
import { getRemoteConfigValues } from '../../../../../../lib/remote-config.server';
import {
Expand All @@ -23,13 +24,10 @@ export type FeedData = FeedDataResult;
* Fetch all data needed for a feed page.
*
* Caching strategy:
* - React cache(): Deduplicates within a single request (layout + page)
* - unstable_cache with user ID: Server-side cache per user across navigations
*
* Each user gets their own cached version that persists across page navigations
* (e.g., /feeds/gtfs/mdb-123 → /feeds/gtfs/mdb-123/map)
*
* Revalidation is short due to the per-user-per-feed cache, but can be adjusted based on needs.
* - React cache(): Deduplicates within a single request (layout + page + metadata)
* - unstable_cache with role: Server-side cache partitioned by role ('admin' | 'authenticated')
* instead of per-user UID, preventing cache bloat across authenticated users.
* - Client-side SWR: Used for user-specific mutations and stale-while-revalidate client navigation.
*/
export const fetchCompleteFeedData = cache(
async (
Expand All @@ -44,7 +42,12 @@ export const fetchCompleteFeedData = cache(
getRemoteConfigValues(),
],
);
const userId = user?.uid ?? 'anonymous';
const userRole =
user?.email && isMobilityDatabaseAdmin(user.email)
? 'admin'
: user
? 'authenticated'
: 'guest';

const cachedFetch = unstable_cache(
async () => {
Expand All @@ -56,10 +59,10 @@ export const fetchCompleteFeedData = cache(
remoteConfig.enableSealOfReliability,
);
},
[`feed-complete-${feedDataType}-${feedId}-${userId}`], // unique cache key per user
[`feed-role-${feedDataType}-${feedId}-${userRole}`], // shared cache key per role instead of per user
{
tags: [`feed-${feedId}`, `user-${userId}`, `feed-type-${feedDataType}`],
revalidate: 600, // 10 minutes - to revisit based on user usage / cache storage availability
tags: [`feed-${feedId}`, `role-${userRole}`, `feed-type-${feedDataType}`],
revalidate: 600, // 10 minutes
},
);

Expand Down
55 changes: 55 additions & 0 deletions src/app/screens/Feed/hooks/useFeedDetailCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import useSWR, { useSWRConfig } from 'swr';
import { type AllFeedType } from '../../../services/feeds/utils';

export interface ClientFeedCacheData {
feed: AllFeedType;
lastUpdated: number;
}

/**
* Key generator for SWR feed detail caching.
*/
export function getFeedDetailCacheKey(feedDataType: string, feedId: string): string {
return `client-feed-${feedDataType}-${feedId}`;
}

/**
* Client-side SWR hook for cached feed detail inquiries.
* Provides instant cached retrieval, stale-while-revalidate,
* and user-specific client caching without bloating Next.js server cache.
*/
export function useFeedDetailCache(
feedDataType: string,
feedId: string,
initialData?: AllFeedType,
) {
const { mutate } = useSWRConfig();
const key = getFeedDetailCacheKey(feedDataType, feedId);

const { data, error, isLoading, isValidating } = useSWR<AllFeedType>(
key,
null, // Initialized from server props, revalidated via mutations or manual refresh
{
fallbackData: initialData,
revalidateOnFocus: false,
revalidateIfStale: false,
},
);

const invalidateFeedCache = () => {
return mutate(key);
};

const updateFeedCache = (updatedFeed: AllFeedType) => {
return mutate(key, updatedFeed, false);
};

return {
feed: data ?? initialData,
error,
isLoading,
isValidating,
invalidateFeedCache,
updateFeedCache,
};
}