Skip to content

Sub-Issue #144.3: Advanced Caching Strategies per Feature #168

@kevalyq

Description

@kevalyq

📌 Parent Epic

#144 (PWA Phase 3 - Remaining Features)

🎯 Goal

Optimize caching for specific features beyond generic strategies to maximize offline functionality and performance. Implement route-specific caching, TTL-based expiration, and intelligent prefetching.


📋 Implementation Tasks

1. Define Caching Strategy per Feature

Tasks:

  • Audit all routes and categorize by caching needs
  • Define strategy per route type (API, static, dynamic)
  • Set TTL (Time-To-Live) per resource type
  • Document caching decisions

Strategy Matrix:

Feature Strategy TTL Rationale
Secrets List NetworkFirst 5 min Fresh data preferred, fallback to cache
Secret Details StaleWhileRevalidate 1 hour Instant load, background refresh
Static Assets (JS/CSS) CacheFirst 1 year Immutable, versioned URLs
Images/Avatars CacheFirst 30 days Rarely change, immutable
API Auth NetworkOnly N/A Never cache credentials
App Shell CacheFirst Forever Core UI, versioned

2. Implement Route-Specific Caching (Service Worker)

Tasks:

  • Configure Workbox routing for each strategy
  • Add cache name prefixes (api-, static-, images-)
  • Set max entries per cache
  • Set max age per cache
  • Add cache busting for versioned assets

Implementation:

// vite.config.ts - Workbox configuration
import { VitePWA } from 'vite-plugin-pwa';

export default defineConfig({
  plugins: [
    VitePWA({
      workbox: {
        runtimeCaching: [
          // API: Secrets (NetworkFirst + TTL)
          {
            urlPattern: /^https:\/\/api\.secpal\.app\/v1\/secrets/,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-secrets',
              expiration: {
                maxEntries: 50,
                maxAgeSeconds: 5 * 60, // 5 minutes
              },
              cacheableResponse: {
                statuses: [0, 200],
              },
              networkTimeoutSeconds: 5,
            },
          },
          
          // API: User Data (StaleWhileRevalidate)
          {
            urlPattern: /^https:\/\/api\.secpal\.app\/v1\/users/,
            handler: 'StaleWhileRevalidate',
            options: {
              cacheName: 'api-users',
              expiration: {
                maxEntries: 20,
                maxAgeSeconds: 60 * 60, // 1 hour
              },
            },
          },
          
          // Images (CacheFirst + long TTL)
          {
            urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
            handler: 'CacheFirst',
            options: {
              cacheName: 'images',
              expiration: {
                maxEntries: 100,
                maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
              },
            },
          },
          
          // Static Assets (CacheFirst + versioned)
          {
            urlPattern: /\.(?:js|css|woff2)$/,
            handler: 'CacheFirst',
            options: {
              cacheName: 'static-assets',
              expiration: {
                maxEntries: 50,
                maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year
              },
            },
          },
          
          // API: Auth (NetworkOnly)
          {
            urlPattern: /^https:\/\/api\.secpal\.app\/v1\/auth/,
            handler: 'NetworkOnly',
          },
        ],
      },
    }),
  ],
});

3. Cache Expiration Policies

Tasks:

  • Implement TTL-based expiration
  • Add max entries limit per cache
  • Implement LRU (Least Recently Used) eviction
  • Add manual cache invalidation API
  • Clear expired caches on app update

Manual Invalidation:

// hooks/useCache.ts
export const useCache = () => {
  const invalidateCache = async (cacheNames: string[]) => {
    if (!('caches' in window)) return;
    
    for (const name of cacheNames) {
      await caches.delete(name);
    }
  };
  
  const clearAllCaches = async () => {
    const cacheNames = await caches.keys();
    await Promise.all(cacheNames.map(name => caches.delete(name)));
  };
  
  return { invalidateCache, clearAllCaches };
};

4. Partial Cache Invalidation on Mutations

Tasks:

  • Invalidate secrets list cache after secret creation
  • Invalidate secret details cache after update
  • Invalidate user cache after profile update
  • Add cache invalidation to mutation hooks
  • Implement optimistic cache updates

Example:

// hooks/useSecretMutation.ts
export const useCreateSecret = () => {
  const { invalidateCache } = useCache();
  
  return useMutation({
    mutationFn: createSecret,
    onSuccess: async () => {
      // Invalidate secrets list cache
      await invalidateCache(['api-secrets']);
      
      // Trigger background sync to refresh
      if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) {
        const registration = await navigator.serviceWorker.ready;
        await registration.sync.register('sync-secrets');
      }
    },
  });
};

5. Prefetching for Likely User Actions

Tasks:

  • Prefetch on app idle (requestIdleCallback)
  • Prefetch on hover (anticipate clicks)
  • Prefetch critical resources on initial load
  • Prefetch user's most-used secrets
  • Add prefetch priority system

Implementation:

// hooks/usePrefetch.ts
export const usePrefetch = () => {
  const prefetchOnIdle = (url: string) => {
    if ('requestIdleCallback' in window) {
      requestIdleCallback(() => {
        fetch(url, { priority: 'low' });
      });
    }
  };
  
  const prefetchOnHover = (url: string) => {
    // Prefetch when user hovers over link
    return {
      onMouseEnter: () => {
        const link = document.createElement('link');
        link.rel = 'prefetch';
        link.href = url;
        document.head.appendChild(link);
      },
    };
  };
  
  return { prefetchOnIdle, prefetchOnHover };
};

// Usage
const SecretCard = ({ secret }) => {
  const { prefetchOnHover } = usePrefetch();
  
  return (
    <Link 
      to={`/secrets/${secret.id}`} 
      {...prefetchOnHover(`/api/v1/secrets/${secret.id}`)}
    >
      {secret.title}
    </Link>
  );
};

6. Cache Performance Monitoring

Tasks:

  • Track cache hit/miss ratio
  • Measure cache lookup time
  • Monitor cache size (storage quota)
  • Log cache evictions
  • Add performance dashboard (dev tools)

Monitoring:

// utils/cacheMonitor.ts
export class CacheMonitor {
  private hits = 0;
  private misses = 0;
  
  recordHit() {
    this.hits++;
    this.reportMetrics();
  }
  
  recordMiss() {
    this.misses++;
    this.reportMetrics();
  }
  
  getHitRatio(): number {
    const total = this.hits + this.misses;
    return total > 0 ? this.hits / total : 0;
  }
  
  private reportMetrics() {
    // Report to analytics
    analytics.track({
      type: 'performance',
      data: {
        metric: 'cache_hit_ratio',
        value: this.getHitRatio() * 100,
        unit: 'percent',
      },
    });
  }
}

7. WebP Optimization for Images

Tasks:

  • Convert images to WebP format (build time)
  • Serve WebP with fallback to PNG/JPEG
  • Lazy load images (Intersection Observer)
  • Add blur placeholder while loading
  • Optimize image sizes (responsive images)

Lazy Loading:

// components/LazyImage.tsx
export const LazyImage: React.FC<Props> = ({ src, alt, placeholder }) => {
  const [loaded, setLoaded] = useState(false);
  const imgRef = useRef<HTMLImageElement>(null);
  
  useEffect(() => {
    if (!imgRef.current) return;
    
    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          const img = entry.target as HTMLImageElement;
          img.src = src;
          img.onload = () => setLoaded(true);
          observer.unobserve(img);
        }
      });
    });
    
    observer.observe(imgRef.current);
    
    return () => observer.disconnect();
  }, [src]);
  
  return (
    <img 
      ref={imgRef} 
      src={placeholder} 
      alt={alt}
      className={loaded ? 'loaded' : 'loading'}
    />
  );
};

8. Cache Testing

Feature Tests:

  • NetworkFirst returns cached data when offline
  • StaleWhileRevalidate serves stale, then updates
  • CacheFirst serves from cache immediately
  • Cache invalidation works on mutations
  • Prefetching queues requests correctly
  • Cache size limits enforced

Unit Tests:

  • Cache strategy selection logic
  • TTL expiration calculation
  • Cache key generation
  • Prefetch prioritization

Coverage Target: ≥80% for new code


✅ Acceptance Criteria

  • Route-specific caching strategies implemented
  • TTL-based cache expiration working
  • Cache invalidation on mutations functional
  • Prefetching for likely actions implemented
  • Cache performance monitoring in place
  • WebP optimization for images
  • Lazy loading for images
  • Cache hit ratio >80% for returning users
  • All tests passing (≥80% coverage)
  • TypeScript strict mode clean
  • ESLint passing
  • REUSE 3.3 compliant (SPDX headers)
  • CHANGELOG.md updated (Added section)
  • App remains fully functional offline

🔗 Dependencies


📝 Technical Notes

Cache Storage API Quotas

  • Chrome/Edge: Dynamic (up to 60% of disk)
  • Firefox: 2GB default (can request more)
  • Safari: 1GB limit

Best Practices

  • ⚠️ Use versioned cache names (e.g., static-v1, static-v2)
  • ⚠️ Clean up old caches on service worker activation
  • ⚠️ Test on slow 3G networks (DevTools throttling)
  • ⚠️ Monitor cache sizes (prevent quota exceeded errors)

Performance Targets

  • Cache Hit Ratio: >80% for returning users
  • Cache Lookup Time: <50ms (p95)
  • Offline Load Time: <2s (cached app shell)
  • Image Load Time: <500ms (cached images)

🧪 Testing Strategy

// Example: NetworkFirst strategy
test('serves cached data when offline (NetworkFirst)', async () => {
  // Populate cache
  await fetch('/api/v1/secrets');
  
  // Go offline
  Object.defineProperty(navigator, 'onLine', { value: false });
  
  // Fetch again
  const response = await fetch('/api/v1/secrets');
  
  // Should return cached data
  expect(response.ok).toBe(true);
  expect(response.headers.get('X-Cache')).toBe('HIT');
});

test('invalidates cache after secret creation', async () => {
  // Create secret
  await createSecret({ title: 'Test' });
  
  // Check cache was invalidated
  const cache = await caches.open('api-secrets');
  const cachedResponse = await cache.match('/api/v1/secrets');
  expect(cachedResponse).toBeUndefined();
});

📝 PR Linking Instructions

When creating the PR for this sub-issue, use:

Fixes #[this-issue-number]
Closes #144

⚠️ Important:


Type: Sub-Issue (Frontend)
Priority: Medium
Estimated Effort: 1 week
Part of: Epic #144 (PWA Phase 3) - FINAL SUB-ISSUE

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Status

    ✅ Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions