Apogée Consult
Retour au blog
Jules Ginhac
Jules GinhacCo-Founder & ingénieur IA

Je suis Jules Ginhac, Co-Founder & ingénieur IA chez Apogée Consult à Lyon. Je conçois et déploie des architectures IA génératives (RAG, agents, LLMOps) pour des PME, startups et organisations publiques.

Latence perçue dans un chat IA : comment on a gagné 800ms ressenties sans toucher au modèle

  • ia-produit

On a réduit la latence perçue de notre interface chat de 800ms sans changer de modèle ni optimiser les prompts. Tout vient du front. Voici les techniques utilisées.

Latence perçue dans un chat IA : comment on a gagné 800ms ressenties sans toucher au modèle

La latence réelle d'un LLM, on ne la maîtrise pas entièrement. La latence perçue, oui. Ce sont deux choses différentes, et la confusion entre les deux pousse les équipes à optimiser les prompts ou à changer de modèle alors que le problème est dans l'interface.

Nous avons mesuré une amélioration de 800ms sur le temps de réponse ressenti après trois changements purement front, sans modifier un seul paramètre côté modèle.

Mesure de départ

Contexte : une interface chat B2B, déployée pour une équipe de 30 personnes. L'assistant répond à des questions sur des documents internes. Modèle : Claude Sonnet via API. Streaming activé.

Métriques initiales (médiane, mesurées avec l'API User Timing) :

  • submit → first_token : 1 420ms
  • submit → first_character_displayed : 1 680ms
  • submit → full_response : 4 200ms (sur des réponses de ~300 tokens)

L'écart entre first_token et first_character_displayed de 260ms était notre premier signal : le streaming arrivait mais ne s'affichait pas immédiatement.

Optimisation 1 : afficher le premier token dès réception

Le problème originel : nous accumulions les chunks SSE jusqu'à avoir un "mot complet" avant de mettre à jour le DOM. L'intention était bonne (éviter d'afficher des demi-mots), le résultat mauvais : un délai systématique de 200 à 400ms entre le premier token reçu et le premier caractère visible.

// Avant : attente d'un délimiteur
let buffer = '';
for await (const chunk of stream) {
  buffer += chunk.delta.text;
  if (buffer.includes(' ') || buffer.includes('\n')) {
    setContent(prev => prev + buffer);
    buffer = '';
  }
}

// Après : affichage immédiat de chaque chunk
for await (const chunk of stream) {
  setContent(prev => prev + chunk.delta.text);
}

Les demi-mots ne posent pas de problème perceptuel quand le texte défile à vitesse normale. Le cerveau les complète. Gain mesuré : -220ms sur first_character_displayed.

Optimisation 2 : skeleton pendant le délai pre-first-token

Avant le premier token, l'interface affichait un spinner centré. Le spinner est perçu comme une attente indéfinie. Un skeleton (placeholder animé en forme de lignes de texte) est perçu comme "quelque chose arrive".

Ce n'est pas qu'une intuition : c'est documenté par les travaux de Luke Wroblewski sur les perceived performance patterns. Le skeleton ne réduit pas le temps de chargement, il réduit la frustration associée.

function MessageSkeleton() {
  return (
    <div className="flex flex-col gap-2 w-full animate-pulse">
      <div className="h-4 bg-neutral-200 rounded w-3/4" />
      <div className="h-4 bg-neutral-200 rounded w-full" />
      <div className="h-4 bg-neutral-200 rounded w-5/6" />
    </div>
  );
}

// Usage dans le composant message
function Message({ content, isStreaming, hasContent }) {
  if (isStreaming && !hasContent) return <MessageSkeleton />;
  return <div>{content}</div>;
}

Pas de gain en millisecondes, mais un score de satisfaction utilisateur qui monte de 6,2 à 7,4 sur 10 lors de notre test interne sur 12 personnes. La latence ressentie diminue même quand la latence réelle ne bouge pas.

Optimisation 3 : pre-warming de la connexion

Le délai entre la soumission du message et le premier token inclut le temps d'établissement de la connexion HTTP. Sur nos utilisateurs en France métropolitaine, ce délai variait entre 80ms et 350ms selon l'état du réseau.

La solution : envoyer une requête de pre-warming dès que l'utilisateur commence à taper (événement input avec debounce à 300ms). Cette requête ne fait rien, elle établit juste la connexion TLS et maintient le socket ouvert.

import { useCallback, useRef } from 'react';

function usePretchConnection(apiUrl: string) {
  const connectionRef = useRef<boolean>(false);

  const prewarm = useCallback(() => {
    if (connectionRef.current) return;
    connectionRef.current = true;

    // Requête HEAD pour établir la connexion sans payload
    fetch(apiUrl, { method: 'HEAD' })
      .catch(() => {}) // Silencieux, c'est du best-effort
      .finally(() => {
        // Réinitialise après 10s pour les sessions longues
        setTimeout(() => { connectionRef.current = false; }, 10_000);
      });
  }, [apiUrl]);

  return prewarm;
}

// Dans le composant
const prewarm = usePretchConnection('/api/chat');
<textarea onInput={prewarm} ... />

Gain mesuré : -180ms sur submit → first_token (médiane). L'effet est plus marqué sur les connexions variables (mobile, VPN).

Résultat consolidé

MétriqueAvantAprèsDelta
submit → first_token1 420ms1 240ms-180ms
submit → first_character_displayed1 680ms1 240ms-440ms
Satisfaction perçue (sur 12 utilisateurs)6,2/107,4/10+1,2

Le gain total sur first_character_displayed est de 440ms en termes réels, et d'environ 800ms en termes perçus si l'on intègre l'effet du skeleton (la mesure subjective de "combien de temps ça a semblé prendre" dans nos entretiens).

Ce qu'on n'a pas fait

Nous n'avons pas changé de modèle. Passer à un modèle plus rapide comme Haiku aurait réduit la latence réelle, au prix d'une qualité de réponse dégradée pour ce cas d'usage.

Nous n'avons pas ajouté de cache côté serveur sur les requêtes. Ce serait la prochaine étape : pour les questions fréquentes et formulées de façon similaire, un cache sémantique (embedding-based) peut réduire le temps de réponse à quelques millisecondes. Mais le corpus de questions de nos utilisateurs est trop varié pour que le hit rate justifie l'infrastructure aujourd'hui.

La question qui reste ouverte : jusqu'où peut-on optimiser la latence perçue sans tromper l'utilisateur ? Un skeleton qui reste trop longtemps devient une promesse brisée. Le curseur est difficile à trouver sans mesure.

Disponible pour de nouveaux projets

Un projet à concrétiser ?
Parlons-en, sans engagement.

Un échange de 30 minutes pour cadrer votre besoin, qualifier la faisabilité et vous proposer une trajectoire claire.

1// kick-off : réponse sous 24h
2const project = await apogee.scope({
3 type: 'web | mobile | IA',
4 timeline: '6 à 16 semaines',
5 approach: 'sur-mesure'
6})
7// → cadrage offert
Latence chat IA : -800ms perçus sans changer de modèle | Apogée Consult