Architecture Logicielle

Le Cache comme Architecture : Pourquoi une Saison à 4× n'a Jamais Bougé la Facture Vercel

Le serverless se facture à l'invocation, pas au visiteur — c'est donc la disposition des caches, pas la courbe de trafic, qui décide de ce qu'on paie. Comment WebOustaou empile les caches navigateur, edge, ISR et données derrière un webhook déclenché par la base, et les deux bugs de cache qui méritaient un article à eux seuls.

27 juil. 2026

La Facture qui Aurait Dû Exploser

Le premier client de WebOustaou est un restaurant saisonnier. Le trafic d'août vaut environ quatre fois celui de février — même site, mêmes pages, même carte, simplement beaucoup plus de monde qui la regarde. Toute l'intuition qu'on a de la tarification serverless dit que la facture de la plateforme devrait suivre cette courbe.

Ça n'a pas été le cas. La consommation est restée essentiellement linéaire sur l'année, et la pleine saison y est invisible. Ce n'est pas de la chance, et ce n'est pas Vercel qui se montre généreux. C'est la conséquence d'une décision prise avant que le trafic n'arrive : traiter la disposition des caches comme un élément de l'architecture, plutôt que comme une passe d'optimisation à lancer le jour où quelque chose rame.

Le Serverless se Facture aux Invocations, pas aux Visiteurs

Le renversement qui rend le reste compréhensible : sur Vercel, une requête servie depuis le cache edge n'atteint jamais votre code. Aucune fonction ne démarre, aucune ne s'exécute, aucune n'est facturée. L'unité de coût est l'invocation, et le nombre d'invocations n'est pas le nombre de visiteurs — c'est le nombre de requêtes qui ratent tous les caches placés devant le calcul.

La question de conception cesse donc d'être « comment rendre cette page rapide » pour devenir « à quelle couche s'arrête cette requête précise, et que faut-il pour qu'elle s'y arrête ». Formulée ainsi, la pile de caches devient un véritable artefact d'architecture — avec des responsables, des TTL et un contrat d'invalidation — au lieu d'un saupoudrage d'exports revalidate que plus personne n'arrive à raisonner.

Les couches traversées par une requête, et le point à partir duquel elle coûte une invocation

La Pile, Couche par Couche

Navigateur. Le badge ouvert/fermé est servi par /api/live-status avec Cache-Control: public, s-maxage=60, stale-while-revalidate=300. Un visiteur qui rafraîchit la page, ou qui fait des allers-retours entre la carte et l'accueil, ne génère tout simplement pas de seconde requête.

Vercel Edge / CDN. Les pages sont prérendues par langue via generateStaticParams. C'est là qu'atterrit l'écrasante majorité d'un samedi chargé : du HTML et des assets statiques, servis depuis un POP, sans que rien de chez nous ne s'exécute. Quatre fois plus de visiteurs signifie quatre fois plus de hits edge, et les hits edge ne sont pas la ligne coûteuse.

Cache de route ISR. /[locale]/menu porte revalidate = 3600 ; /api/live-status porte revalidate = 60. C'est la première couche où un miss coûte quelque chose — une route périmée déclenche une régénération, et une régénération est une invocation. Point crucial : cette régénération a lieu une fois par chemin et par fenêtre, quel que soit le nombre de personnes qui l'attendent.

Data cache Next.js. Sous le rendu de la page, unstable_cache enveloppe chaque lecture Supabase avec un tag et un TTL explicites : hours à 3600s, closings à 300s (clé par langue, toutes les langues partageant le tag), messages à côté. Même une page en cours de régénération ne touche donc pas forcément la base.

Supabase, puis JSON statique. L'origine est un PostgREST anonyme en lecture seule, interrogé en fetch natif. Derrière se trouve un repli JSON statique livré dans le dépôt, atteint soit par le kill-switch SUPABASE_READS_DISABLED, soit parce que la lecture lève une erreur. Le site se dégrade vers les horaires d'hier ; il ne se dégrade pas vers une 500.

Mettre en Cache la Donnée, pas la Réponse

/api/hours est déclarée export const dynamic = 'force-dynamic', ce qui ressemble à une erreur dans un article sur le cache. Ce n'en est pas une. Le route handler n'est délibérément pas mis en cache, et la mise en cache est descendue d'un cran, dans unstable_cache à l'intérieur de hours-storage.ts.

La raison, c'est l'invalidation. Une réponse en cache s'invalide par le temps, ou par un chemin. Une lecture de donnée en cache s'invalide par un tag — et un tag peut porter le nom d'un concept métier. Quand quelqu'un modifie les horaires, l'événement qui s'est produit est « les horaires ont changé », pas « l'URL /api/hours a changé ». Taguer la lecture fait que le même revalidateTag('hours') l'invalide, qu'elle ait été consommée par une route d'API, par un composant serveur, ou par quelque chose qui n'existe pas encore. Mettre la réponse en cache aurait acheté une requête un peu moins chère en échange de la capacité à invalider sur ce qui s'est réellement passé.

L'Invalidation est la Moitié Difficile

Les TTL seuls imposent un mauvais arbitrage : assez courts pour que les modifications apparaissent vite, assez longs pour ne pas régénérer en permanence. Les deux objectifs tirent en sens inverse, et on finit par choisir un chiffre qui ne satisfait personne.

Un webhook supprime l'arbitrage. Des triggers Postgres pg_net sur products, opening_hours, closures et messages envoient un POST à /api/revalidate avec un secret partagé dans x-revalidate-secret, comparé par timingSafeEqual. Le handler associe la table à la bonne invalidation : une écriture sur la carte invalide les chemins de la carte, une écriture sur les horaires invalide les chemins de l'accueil plus revalidateTag('hours') et revalidateTag('live-status'), et ainsi de suite.

Le point intéressant est ce que cela fait au TTL. Dès lors qu'un webhook existe, le TTL n'est plus le mécanisme qui fait apparaître les modifications — c'est le filet de sécurité en cas de défaillance du webhook. On le règle donc en se demandant « quelle péremption peut-on tolérer si le webhook casse en silence », ce qui donne une heure pour une carte, plutôt que « en combien de temps une modification doit-elle apparaître », ce qui aurait donné quelques secondes. Même fraîcheur, une fraction des régénérations.

Une Table de Politique, Trois Couches

Les TTL ci-dessus apparaissent à plus d'un endroit. L'appel unstable_cache a besoin d'un revalidate et d'un tag. La couche de requêtes côté client a besoin d'un staleTime. Le récepteur de webhook doit savoir quelles tables correspondent à quel domaine pour invalider ce qu'il faut. Écrits séparément, ces trois-là dérivent — et le mode de défaillance est silencieux, parce que rien ne plante quand un client considère comme fraîche une donnée que le serveur juge périmée.

Ils ne sont donc pas écrits séparément. Un module unique, cache-policy.ts, exporte une seule table : pour chaque domaine de cache — carte, horaires, fermetures, messages, statut en direct, articles, happenings, offres — un ttlSeconds, un staleTimeMs, et la liste des tables Supabase dont les mutations l'invalident. Les trois couches lisent ce même objet, et le webhook remonte de body.table au domaine par la même table.

Le module ne dépend ni de React, ni de TanStack Query, ni de Next.js — juste des constantes et trois helpers. Cette contrainte est délibérée : un module de politique qui importe un framework ne peut être lu que par ce framework, alors que tout l'intérêt est qu'une edge function ou un service séparé puisse l'importer plus tard sans rien traîner derrière. Ce qui s'avère avoir son importance, pour des raisons que la dernière section aborde.

Deux Bugs qui Mériteraient un Article à Eux Seuls

revalidatePath avec un segment dynamique ne fait pas ce qu'il semble faire. Appeler revalidatePath('/[locale]/menu') n'invalide pas de façon fiable les pages prérendues via generateStaticParams. Le webhook partait, renvoyait 200, journalisait un succès — et la page continuait de servir l'ancien prix. Le correctif consiste à boucler sur les langues et à invalider les chemins concrets : /fr/menu, /en/menu. Un chemin d'invalidation qui signale un succès sans rien faire est pire que pas d'invalidation du tout, parce qu'il supprime toute incitation à aller regarder.

Ne jamais mettre en cache une réponse dégradée. /api/live-status ne renvoie s-maxage=60 que sur le chemin sain. La réponse du kill-switch et la réponse d'erreur renvoient toutes deux no-store. Sans cela, un simple hoquet Supabase se retrouve épinglé dans le cache edge et chaque visiteur voit « horaires inconnus » pendant tout le TTL — une panne de deux secondes amplifiée en panne d'une minute. Les en-têtes de cache s'écrivent généralement sur la branche de succès puis se recopient sur les autres ; la branche d'erreur est précisément celle où la copie est fausse.

Le Prouver Plutôt que le Croire

Un cache qu'on n'a pas testé dans les deux sens, ce sont deux bugs en attente : un où il ne retient rien, un où il ne lâche jamais. scripts/verify-cache.sh exécute les deux cas contre un Supabase local et un build de production.

Le premier test désactive le webhook, change un prix en base, et vérifie que la page ne se met pas à jour — preuve que le cache retient réellement. Le second réactive le webhook, rechange le prix, et vérifie que la page se met bien à jour dans le délai imparti — preuve que trigger, pg_net, vérification du secret et revalidatePath s'enchaînent de bout en bout. Le script refuse par ailleurs de démarrer si l'en-tête x-nextjs-cache est absent de la réponse, parce que l'ISR n'existe pas sous next dev et que tout le test passerait sinon à vide contre un serveur de développement.

Ce que la Saison a Réellement Fait

Durée des fonctions Vercel par projet, par semaine, de fin avril à fin juillet

Voilà la durée des fonctions sur Vercel, ventilée par projet, semaine après semaine, pendant la montée en haute saison. La bande bleue, c'est darius-pizza-site — le site client, celui dont les visiteurs quadruplent. Il se maintient autour de quinze à vingt minutes de temps de fonction chaque semaine, et c'est la chose la plus plate du graphique. C'est aussi 56 % de tout ce qui est affiché, et c'est le détail sur lequel il faut s'arrêter : la propriété la plus fréquentée du compte est celle qui apporte le moins de variance.

Tout ce qui s'empile au-dessus, c'est le code de la plateforme en développement actif — le site marketing, l'application admin, le template de site. C'est ce qui fait grandir les barres les plus récentes. La croissance sur ce graphique, c'est moi qui déploie, pas des clients qui arrivent. (La dernière barre est une semaine incomplète.)

La forme découle de la conception et non du trafic. Les invocations sont grossièrement bornées par le nombre de chemins en cache multiplié par la fréquence d'expiration de chaque fenêtre, plus les invalidations déclenchées par les modifications du propriétaire, plus les miss à froid. Le nombre de visiteurs n'apparaît nulle part dans cette expression.

Un propriétaire qui modifie ses horaires trois fois par jour produit le même travail d'invalidation en février qu'en août. La carte se régénère à son propre rythme, indépendamment de qui la consulte. Ce qui a quadruplé en haute saison, ce sont les hits edge et la bande passante — précisément les couches conçues pour absorber ça. La couche de calcul, elle, n'a jamais su que c'était l'été.

Le Hub Construit et Laissé Éteint

Tout ce qui précède décrit un seul site. L'arithmétique change avec le deuxième. N sites clients qui mettent chacun en cache de leur côté, c'est au pire N × M lectures non cachées vues par Postgres, face à des limites de pool de connexions bien réelles. L'invalidation se fragmente aussi : un webhook de base qui doit prévenir directement chaque site client a besoin de l'URL de chacun, si bien que provisionner un client devient une modification de configuration dans un endroit que personne ne pense à modifier.

La réponse conçue est un hub de lecture central — GET /api/v1/{slug}/{endpoint} devant un unique Redis Upstash mutualisé, avec des clés tenant:{id}:{endpoint}. Un seul Redis pour N clients, une seule table de gouvernance des TTL, une seule URL de webhook permanente qui DEL les clés concernées puis relaie vers le /api/revalidate de chaque site. WebOustaou devient un multiplexeur de connexions devant Postgres, au lieu que chaque site fasse la queue à la porte.

C'est construit. C'est aussi éteint — CACHE_ENABLED=false en production, true en local et en CI. La prod lit toujours Supabase directement, parce qu'avec un seul client en ligne le hub achète une mutualisation dont personne n'a besoin, au prix d'un saut réseau supplémentaire et d'un nouveau point de défaillance unique pour toutes les lectures. La version honnête de « on a un hub de cache », c'est : le code existe, les tests tournent dessus, et le flag reste à false tant qu'un deuxième client ne rend pas l'échange intéressant.

C'est la partie que je défendrais le plus fermement. Construire la couche tôt était bon marché — elle réutilise les mêmes TTL de cache-policy.ts, et le formateur de tags préfixe déjà par client, si bien que le template était compatible hub avant que le hub n'existe. L'allumer tôt aurait acheté de la latence et une surface de panne en échange d'une propriété de passage à l'échelle qui ne s'applique pas encore. Ce sont deux décisions différentes, et les confondre est exactement la façon dont une architecture s'alourdit.

Ce que Ça Coûte

Ce n'est pas gratuit, et les coûts sont bien réels. Chaque couche est une fenêtre de péremption qu'on a acceptée, et quelqu'un doit assumer ce chiffre pour chaque type de donnée. Le webhook est une dépendance qui peut tomber en silence, ce qui rend le filet de sécurité du TTL non négociable. Il y a plus de caches que d'endroits où regarder quand l'un d'eux se trompe, et « le site affiche de vieux horaires » a au moins quatre coupables possibles avant qu'on n'atteigne la base. Et le repli statique est une promesse : si personne ne l'entretient, « se dégrade proprement » est un commentaire, pas un comportement.

L'intérêt n'est pas que le cache soit malin. C'est que la pile de caches est une pièce porteuse de l'architecture, avec les mêmes droits que le schéma à être conçue, documentée et testée — et que la décider en amont est ce qui transforme un pic de trafic saisonnier en non-événement.