Pourquoi le cache frontend est un sujet d’architecture

Un cache frontend ne sert pas seulement à éviter quelques appels HTTP. Bien conçu, il améliore la réactivité perçue, réduit la charge serveur et rend l’interface plus stable lorsque le réseau est lent. Mal conçu, il affiche des données obsolètes, masque des erreurs et rend les bugs difficiles à reproduire.

Dans une application web moderne, les données viennent souvent d’API REST, GraphQL, de fonctions server-side ou de services tiers. On peut déjà sécuriser la forme des réponses avec une approche de typage des données API en TypeScript, mais il faut aussi décider combien de temps ces réponses restent réutilisables côté client.

L’objectif de cet article est de construire une stratégie simple, compréhensible et extensible : le cache stale-while-revalidate.

Comprendre stale-while-revalidate

La stratégie stale-while-revalidate consiste à afficher immédiatement une donnée déjà présente en cache, même si elle est potentiellement un peu ancienne, puis à relancer une requête en arrière-plan pour obtenir une version fraîche.

Le principe est le suivant :

  1. Si aucune donnée n’est disponible, on charge depuis le réseau.
  2. Si une donnée récente existe, on l’utilise directement.
  3. Si une donnée ancienne existe, on l’affiche quand même, mais on déclenche une mise à jour.
  4. Si la mise à jour réussit, le cache est remplacé.
  5. Si elle échoue, l’ancienne donnée peut rester visible avec un état d’erreur discret.

Cette approche est particulièrement utile pour les tableaux de bord, profils utilisateur, catalogues, listes d’articles, filtres de recherche ou interfaces d’administration.

Elle ne convient pas à tous les cas. Pour un paiement, une réservation ou une action critique, vous devez privilégier une donnée fraîche, validée côté serveur. Pour une interface de consultation, le compromis est souvent excellent.

Définir une entrée de cache typée

Commençons par représenter une entrée de cache en TypeScript.

type CacheStatus = 'fresh' | 'stale';

interface CacheEntry<T> {
  data: T;
  createdAt: number;
  updatedAt: number;
  maxAge: number;
}

function getCacheStatus<T>(entry: CacheEntry<T>, now = Date.now()): CacheStatus {
  return now - entry.updatedAt <= entry.maxAge ? 'fresh' : 'stale';
}

Ici, maxAge est exprimé en millisecondes. Une donnée peut donc être considérée comme fraîche pendant 30 secondes, 5 minutes ou 1 heure selon sa nature.

Pour des données très dynamiques, comme une notification ou un stock produit, utilisez une durée courte. Pour des données rarement modifiées, comme une liste de catégories, une durée plus longue est acceptable.

Construire des clés de cache fiables

Une erreur fréquente consiste à utiliser uniquement l’URL comme clé. Cela fonctionne pour des cas simples, mais devient fragile dès que l’authentification, la pagination, les filtres ou la langue entrent en jeu.

Une clé de cache doit représenter exactement la requête logique.

type CacheKeyPart = string | number | boolean | null | undefined;

function createCacheKey(parts: CacheKeyPart[]): string {
  return parts
    .map((part) => String(part ?? ''))
    .join(':');
}

const key = createCacheKey([
  'articles',
  'page',
  2,
  'tag',
  'typescript',
  'locale',
  'fr'
]);

Cette clé est plus explicite que /api/articles?page=2&tag=typescript. Elle évite aussi de mélanger deux variantes d’une même ressource.

Pour une application plus avancée, vous pouvez sérialiser un objet trié afin d’éviter les différences d’ordre entre paramètres.

function stableStringify(value: Record<string, unknown>): string {
  return JSON.stringify(
    Object.keys(value)
      .sort()
      .reduce<Record<string, unknown>>((acc, key) => {
        acc[key] = value[key];
        return acc;
      }, {})
  );
}

Implémenter un cache mémoire minimal

Un cache mémoire est perdu au rechargement de la page, mais il est très rapide et souvent suffisant pour éviter des requêtes répétées pendant une session.

class MemoryCache {
  private entries = new Map<string, CacheEntry<unknown>>();

  get<T>(key: string): CacheEntry<T> | undefined {
    return this.entries.get(key) as CacheEntry<T> | undefined;
  }

  set<T>(key: string, data: T, maxAge: number): CacheEntry<T> {
    const now = Date.now();
    const entry: CacheEntry<T> = {
      data,
      createdAt: now,
      updatedAt: now,
      maxAge
    };

    this.entries.set(key, entry);
    return entry;
  }

  delete(key: string): void {
    this.entries.delete(key);
  }

  clear(): void {
    this.entries.clear();
  }
}

export const cache = new MemoryCache();

Cette classe ne dépend d’aucun framework. Elle peut être utilisée dans une application React, Vue, Nuxt, Next.js ou même dans du JavaScript vanilla.

Ajouter une fonction stale-while-revalidate

Créons maintenant une fonction qui lit le cache, retourne rapidement une donnée si possible, et déclenche une mise à jour lorsque nécessaire.

interface CachedResult<T> {
  data: T;
  status: 'fresh' | 'stale' | 'revalidated';
}

interface FetchWithCacheOptions<T> {
  key: string;
  maxAge: number;
  fetcher: () => Promise<T>;
  onRevalidate?: (data: T) => void;
  onError?: (error: unknown) => void;
}

async function fetchWithCache<T>({
  key,
  maxAge,
  fetcher,
  onRevalidate,
  onError
}: FetchWithCacheOptions<T>): Promise<CachedResult<T>> {
  const entry = cache.get<T>(key);

  if (entry) {
    const status = getCacheStatus(entry);

    if (status === 'fresh') {
      return { data: entry.data, status: 'fresh' };
    }

    fetcher()
      .then((data) => {
        cache.set(key, data, maxAge);
        onRevalidate?.(data);
      })
      .catch((error) => {
        onError?.(error);
      });

    return { data: entry.data, status: 'stale' };
  }

  const data = await fetcher();
  cache.set(key, data, maxAge);

  return { data, status: 'revalidated' };
}

Ce code illustre bien le compromis : si la donnée est périmée mais disponible, l’utilisateur n’attend pas. La requête réseau se fait ensuite sans bloquer l’affichage.

Exemple avec une API typée

Imaginons une route /api/products qui retourne une liste de produits. Dans une vraie application, vous devriez valider les données reçues, notamment si elles viennent d’un service externe.

interface Product {
  id: string;
  name: string;
  price: number;
}

async function fetchProducts(category: string): Promise<Product[]> {
  const response = await fetch(`/api/products?category=${category}`);

  if (!response.ok) {
    throw new Error('Impossible de charger les produits');
  }

  return response.json() as Promise<Product[]>;
}

const result = await fetchWithCache<Product[]>({
  key: createCacheKey(['products', 'category', 'books']),
  maxAge: 60_000,
  fetcher: () => fetchProducts('books'),
  onRevalidate: (products) => {
    console.log('Produits mis à jour', products.length);
  },
  onError: (error) => {
    console.error('Erreur de revalidation', error);
  }
});

Pour une application plus robuste, combinez cette approche avec une validation runtime, par exemple avec Zod, afin de ne jamais stocker en cache une réponse invalide.

Intégration dans un hook React

Dans React, vous pouvez encapsuler cette logique dans un hook. L’idée est de gérer trois états : chargement initial, donnée disponible et erreur éventuelle.

import { useEffect, useState } from 'react';

interface UseCachedDataState<T> {
  data: T | null;
  loading: boolean;
  stale: boolean;
  error: string | null;
}

function useCachedData<T>(
  key: string,
  fetcher: () => Promise<T>,
  maxAge: number
): UseCachedDataState<T> {
  const [state, setState] = useState<UseCachedDataState<T>>({
    data: null,
    loading: true,
    stale: false,
    error: null
  });

  useEffect(() => {
    let mounted = true;

    fetchWithCache<T>({
      key,
      maxAge,
      fetcher,
      onRevalidate: (data) => {
        if (!mounted) return;
        setState({ data, loading: false, stale: false, error: null });
      },
      onError: () => {
        if (!mounted) return;
        setState((current) => ({
          ...current,
          loading: false,
          error: 'La mise à jour a échoué'
        }));
      }
    })
      .then((result) => {
        if (!mounted) return;
        setState({
          data: result.data,
          loading: false,
          stale: result.status === 'stale',
          error: null
        });
      })
      .catch(() => {
        if (!mounted) return;
        setState({ data: null, loading: false, stale: false, error: 'Chargement impossible' });
      });

    return () => {
      mounted = false;
    };
  }, [key, maxAge]);

  return state;
}

Ce hook reste volontairement minimal. Dans un projet professionnel, vous pouvez utiliser TanStack Query ou SWR, qui implémentent déjà beaucoup de mécanismes avancés. Mais comprendre ce modèle vous aidera à mieux configurer ces bibliothèques.

Invalidation : le vrai point sensible

Le cache devient dangereux lorsqu’on oublie de l’invalider après une mutation. Si un utilisateur modifie un produit, une tâche ou son profil, les données associées doivent être supprimées ou remplacées.

async function updateProduct(product: Product): Promise<Product> {
  const response = await fetch(`/api/products/${product.id}`, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(product)
  });

  if (!response.ok) {
    throw new Error('Mise à jour impossible');
  }

  const updatedProduct = (await response.json()) as Product;

  cache.delete(createCacheKey(['product', product.id]));
  cache.delete(createCacheKey(['products', 'category', 'books']));

  return updatedProduct;
}

L’invalidation par clé exacte est simple, mais parfois insuffisante. Dans une grande application, vous pouvez regrouper les clés par préfixe ou par tags logiques : products, user, dashboard, settings.

Cache HTTP, cache applicatif et Server Components

Il ne faut pas confondre plusieurs niveaux de cache. Le cache HTTP est géré par le navigateur, le CDN ou le serveur via des en-têtes comme Cache-Control. Le cache applicatif, lui, est contrôlé par votre code JavaScript.

Dans une application Next.js moderne, certains choix peuvent être faits côté serveur, notamment avec les React Server Components. Cela peut réduire la quantité de JavaScript envoyée au navigateur et déplacer une partie du cache plus près des données.

Côté client, le cache reste pertinent pour les interactions dynamiques : filtres, recherches, onglets, autocomplétion, pagination instantanée. Si les traitements deviennent coûteux, vous pouvez aussi déporter certains calculs dans des Web Workers en JavaScript afin de préserver la fluidité du thread principal.

Bonnes pratiques à retenir

Un cache frontend efficace repose sur quelques règles simples :

  • donnez une durée de vie différente selon la nature des données ;
  • construisez des clés déterministes et explicites ;
  • ne mettez jamais en cache une réponse non validée ou ambiguë ;
  • invalidez après chaque mutation significative ;
  • affichez clairement les états de chargement, d’erreur et de donnée périmée ;
  • mesurez l’impact sur la performance réelle, pas seulement le nombre de requêtes évitées.

Vous pouvez aussi améliorer l’expérience visuelle autour du chargement avec des transitions discrètes. Une mise à jour de données peut, par exemple, être accompagnée d’une animation légère via la View Transitions API, à condition de respecter les préférences d’accessibilité de l’utilisateur.

Conclusion

Le cache frontend n’est pas une optimisation secondaire. C’est une décision d’architecture qui influence la performance, la cohérence des données et la qualité perçue de votre interface.

La stratégie stale-while-revalidate offre un excellent point d’équilibre : l’utilisateur voit rapidement une donnée disponible, tandis que l’application travaille en arrière-plan pour la rafraîchir. En combinant clés fiables, validation TypeScript, invalidation explicite et mesure de performance, vous obtenez une base saine pour des interfaces plus rapides et plus robustes.