Introduction
Dans la continuité de mes articles sur Kejja, je vous propose de revenir sur un problème que je n’avais pas suffisamment anticipé : le temps d’attente avant l’affichage des recommandations. Entre les appels aux services externes, les calculs de sélection et la génération des explications par le LLM, les délais s’additionnent. Et lorsque tout se déroule dans la même requête HTTP, cela finit par se ressentir.
Pour rappel, Kejja est un projet Symfony full stack, sans front-end séparé. Le serveur prépare les données et génère les pages. La façon dont on organise ces traitements a donc un effet direct sur ce que l’utilisateur voit… et sur le temps qu’il passe à attendre.
Nous allons voir comment j’ai identifié le problème, ce que j’ai changé pour améliorer le chargement, puis comment les mesures m’ont amené à envisager la suite. Comme toujours, je partage ici mon approche, avec ses compromis et ses limites : une réponse aux besoins actuels de Kejja, qui pourra évoluer avec le projet.
Le souci : une requête HTTP qui attend la génération
Une fois le compte créé, le parcours de Kejja suit quelques étapes :
- L’utilisateur se connecte.
- Il choisit le type de contenu et son humeur du moment, ou passe cette étape.
- Kejja génère ses recommandations.
- Les trois contenus s’affichent.
Le point de blocage se situait dans la génération. Entre les entrées-sorties, la prise en compte des préférences et de l’humeur, puis la préparation du résultat, plusieurs traitements devaient se terminer avant que la page puisse s’afficher. Lors des premières mesures, cela représentait environ deux à trois secondes pendant lesquelles l’utilisateur attendait, sans retour visuel suffisamment clair.
La génération était exécutée de manière synchrone dans la requête HTTP. Tant que le résultat n’était pas prêt, le serveur ne renvoyait pas la page et le worker PHP restait occupé par cette requête, y compris pendant l’attente des services externes.
Sur cette configuration, je ne disposais que de deux workers FrankenPHP. Deux générations simultanées suffisaient donc à les occuper tous les deux. Les autres requêtes ayant besoin de ces workers devaient patienter.
Si cinquante utilisateurs arrivaient au même moment, le serveur ne lancerait pas pour autant cinquante générations en parallèle : les requêtes s’accumuleraient en attente de disponibilité. Le risque était donc de prolonger les temps de réponse, y compris pour des actions sans rapport avec la génération de recommandations.
À ce stade, deux problèmes se superposaient : la disponibilité des workers et l’attente perçue par l’utilisateur. Ils étaient liés, mais améliorer l’un ne suffirait pas forcément à résoudre l’autre.
Au départ, ma priorité était de construire le parcours, d’enchaîner correctement les étapes et de faire fonctionner le moteur de recommandation. Maintenant que le projet avait gagné en maturité, il devenait naturel de travailler davantage l’expérience : que voit l’utilisateur pendant le traitement, combien de temps attend-il et à quel moment peut-on lui afficher un premier résultat utile ?
Turbo et Stimulus à la rescousse
La première évolution a consisté à séparer l’enregistrement de l’humeur du chargement des recommandations. Le POST enregistre le choix, puis redirige vers le dashboard, sans attendre que toute la génération soit terminée.
Dans la vue Twig, une Turbo Frame délimite la zone des recommandations. Lorsqu’une génération est nécessaire, son attribut src indique l’URL à charger, et loading="lazy" permet de déclencher ce chargement lorsque la frame devient visible.
Le dashboard peut ainsi s’afficher avec un squelette à la place des cartes. Ses dimensions reprennent au plus près celles du contenu final avec un écart résiduel de douze pixels ou moins sur les cas vérifiés pour limiter les déplacements de mise en page. Un indicateur dans la barre supérieure accompagne également l’attente.
Une fois la réponse reçue, Turbo remplace le contenu de la frame sans recharger toute la page. Il s’agit bien d’une requête HTTP en arrière-plan, mais sans interrogation répétée du serveur : aucun polling n’est nécessaire. Un Turbo Stream n’est pas non plus indispensable ici : la réponse contenant la frame correspondante suffit.
Renouveler les recommandations avec Stimulus
Le contrôleur Stimulus reprend ce principe lorsque l’utilisateur demande un nouveau lot.
La méthode refreshRecommendations() récupère l’URL depuis le src de la frame, ou depuis son attribut data-srclorsque les recommandations du jour étaient déjà présentes dans le HTML initial. Elle conserve ainsi le filtre de type de contenu courant.
Elle désactive ensuite le bouton pour limiter les doubles clics, puis ajoute deux paramètres :
refresh=1, pour demander un nouveau lot, comptabilisé dans le quota ;refresh_ts, pour produire une URL différente à chaque demande et déclencher un nouveau chargement.
Le contenu de la frame est immédiatement remplacé par une copie du squelette. La modification de son attribut srcdéclenche ensuite la requête Turbo ; les nouvelles recommandations prennent la place du squelette lorsque la réponse arrive.
Un dashboard disponible plus tôt
Cette séparation a permis de diviser par quatre le temps de chargement du dashboard dans mes mesures. L’utilisateur retrouve plus rapidement la structure de la page et un retour visuel sur le chargement en cours.
Cela ne signifie pas que le moteur génère les recommandations quatre fois plus vite. La génération se déroule toujours dans une requête HTTP, qui occupe un worker jusqu’à sa fin. Nous avons déplacé cette attente dans une zone dédiée de la page.
C’est une première amélioration de l’expérience, mais elle laisse une question ouverte : maintenant que le dashboard s’affiche rapidement, comment réduire le temps passé devant le squelette ?
Un même template pour deux situations
Toutes les visites du dashboard ne nécessitent pas une nouvelle génération. Lorsqu’un lot de recommandations est déjà disponible, les cartes sont directement rendues dans la page initiale.
Dans ce cas, la frame ne possède pas d’attribut src : elle n’a rien à charger. L’URL est simplement conservée dans data-src, pour permettre au contrôleur Stimulus de déclencher un renouvellement lorsque l’utilisateur clique sur « Rafraîchir ».
<turbo-frame
id="dashboard-recommendations"
{% if frame_data_src|default(null) %}
data-src="{{ frame_data_src }}"
data-action="turbo:frame-load->dashboard#recommendationsLoaded"
{% endif %}
>
{# Cartes et états de réponse #}
</turbo-frame>Lorsqu’une génération est nécessaire, le dashboard affiche d’abord le squelette et charge la route dédiée. Cette route renvoie le même template, avec le même identifiant dashboard-recommendations, pour que Turbo retrouve le contenu à insérer.
La frame gère également plusieurs résultats possibles : les recommandations disponibles, le quota atteint, un échec de génération ou l’absence de contenu adapté. Un incident technique ne doit pas être présenté comme une absence de recommandations. Le message et l’action proposée ne sont pas les mêmes.
En cas d’échec, le lien « Réessayer » cible la route des recommandations sans ajouter refresh=1, réservé à la demande explicite d’un nouveau lot. L’utilisateur peut ainsi relancer le chargement dans la frame, sans quitter le dashboard.
Un contrôleur Stimulus pour accompagner l’attente
Afficher un squelette ne suffit pas : il faut aussi gérer ce qui se passe lorsque le chargement dure ou échoue. Ce contrôleur Stimulus est attaché au placeholder à l’intérieur de la Turbo Frame. Il pilote trois éléments : le message d’attente, le squelette et le bloc d’erreur.
Son cycle de vie suit celui du placeholder. Lors de sa connexion au DOM, connect() récupère la frame parente, installe les écouteurs d’erreur et démarre l’attente. Lorsque Turbo insère les recommandations à la place du placeholder, Stimulus appelle disconnect(). Les écouteurs et les minuteurs sont alors supprimés.
Ce nettoyage évite de laisser un ancien minuteur déclencher une erreur après l’arrivée des résultats.
Des messages temporisés, sans requêtes supplémentaires
La méthode startWaiting() affiche le squelette, masque l’erreur et positionne aria-busy="true" pour signaler que la zone est en cours de mise à jour.
Trois messages se succèdent ensuite :
- immédiatement : « On regarde ce qui est dispo sur vos plateformes… » ;
- après deux secondes : « On croise avec vos goûts… » ;
- après quatre secondes : « Presque prêt… ».
Le dernier reste affiché jusqu’à l’arrivée du contenu ou au déclenchement de l’erreur.
Ces messages accompagnent l’attente ; ils ne constituent pas un suivi réel des étapes de génération. Le navigateur ne demande pas régulièrement au serveur où il en est. Le setInterval change uniquement le texte affiché, sans effectuer de polling.
Avant chaque démarrage, startWaiting() appelle stopWaiting() pour supprimer les minuteurs précédents. Une nouvelle tentative ne crée donc pas plusieurs séquences concurrentes de messages.
Prévoir une sortie lorsque le chargement échoue
Le contrôleur réagit à deux événements : turbo:fetch-request-error pour un échec de la requête réseau, et turbo:frame-missing lorsque la réponse ne contient pas la frame attendue.
Dans ce second cas, event.preventDefault() empêche le traitement par défaut de Turbo, afin de présenter le message d’erreur prévu dans l’interface.
Un délai de vingt secondes déclenche également showError(). Cette méthode arrête les minuteurs, masque le squelette et le message d’attente, puis affiche le bloc proposant de réessayer. Cela évite notamment de laisser « Presque prêt… » au-dessus d’un message d’échec.
Ce délai est une limite d’attente dans l’interface : il n’annule pas la requête HTTP ni le traitement côté serveur.Une réponse peut encore arriver après son expiration.
Réessayer sans demander explicitement un nouveau lot
Le bouton de reprise appelle retry(), qui réinitialise l’affichage puis relance le chargement de la frame.
Une précaution concerne l’URL : si la tentative précédente provenait d’un rafraîchissement manuel, elle peut encore contenir refresh=1 et refresh_ts. La méthode sourceWithoutRefresh() retire ces deux paramètres tout en conservant les autres, notamment le filtre de type de contenu.
L’objectif est de retenter le chargement du lot du jour, sans redemander explicitement un nouveau lot comptabilisé dans le quota. La gestion effective de ce quota reste du ressort du serveur.
Si l’URL a été nettoyée, son affectation à src déclenche le chargement. Sinon, frame.reload() recharge directement la source actuelle.
Le rôle de ce contrôleur reste ainsi limité : rendre l’attente compréhensible, afficher une possibilité de reprise et nettoyer ses ressources lorsque les recommandations prennent sa place.
import { Controller } from '@hotwired/stimulus';
// Squelette de la frame « dashboard-recommendations » (JEA-135).
// Posé sur le placeholder : Turbo remplace la frame au chargement, donc ce contrôleur
// est déconnecté et tous ses timers et écouteurs sont libérés avec lui.
// Messages d'attente, dans l'ordre. Le dernier reste affiché tant que ça dure.
const MESSAGES = [
'On regarde ce qui est dispo sur vos plateformes…',
'On croise avec vos goûts…',
'Presque prêt…',
];
const MESSAGE_INTERVAL_MS = 2000;
// Au-delà, on arrête d'attendre et on propose de réessayer.
const TIMEOUT_MS = 20000;
export default class extends Controller {
static targets = ['message', 'skeleton', 'error'];
connect() {
this.frame = this.element.closest('turbo-frame');
this.handleFrameError = () => this.showError();
this.handleFrameMissing = (event) => {
// Sans ça, Turbo affiche « Content missing » dans la frame : on montre notre erreur à la place.
event.preventDefault();
this.showError();
};
if (this.frame) {
this.frame.addEventListener('turbo:fetch-request-error', this.handleFrameError);
this.frame.addEventListener('turbo:frame-missing', this.handleFrameMissing);
}
this.startWaiting();
window.lucide?.createIcons?.();
}
disconnect() {
if (this.frame) {
this.frame.removeEventListener('turbo:fetch-request-error', this.handleFrameError);
this.frame.removeEventListener('turbo:frame-missing', this.handleFrameMissing);
}
this.stopWaiting();
}
retry(event) {
event.preventDefault();
this.startWaiting();
if (!this.frame) {
return;
}
const cleanSource = this.sourceWithoutRefresh();
if (cleanSource !== null && cleanSource !== this.frame.getAttribute('src')) {
// Le src porte encore refresh=1 (rafraîchissement raté) : on repart sur le panier
// du jour. Surtout pas de refresh=1 ici, un nouveau lot consommerait le quota.
this.frame.setAttribute('src', cleanSource);
} else {
this.frame.reload();
}
}
startWaiting() {
this.stopWaiting();
this.skeletonTarget.hidden = false;
this.errorTarget.hidden = true;
this.element.setAttribute('aria-busy', 'true');
if (this.hasMessageTarget) {
this.messageTarget.hidden = false;
}
this.messageIndex = 0;
this.renderMessage();
this.messageTimer = window.setInterval(() => this.nextMessage(), MESSAGE_INTERVAL_MS);
this.timeoutTimer = window.setTimeout(() => this.showError(), TIMEOUT_MS);
}
stopWaiting() {
if (this.messageTimer) {
window.clearInterval(this.messageTimer);
this.messageTimer = null;
}
if (this.timeoutTimer) {
window.clearTimeout(this.timeoutTimer);
this.timeoutTimer = null;
}
}
nextMessage() {
if (this.messageIndex >= MESSAGES.length - 1) {
window.clearInterval(this.messageTimer);
this.messageTimer = null;
return;
}
this.messageIndex += 1;
this.renderMessage();
}
renderMessage() {
if (this.hasMessageTarget) {
this.messageTarget.textContent = MESSAGES[this.messageIndex];
}
}
showError() {
this.stopWaiting();
this.skeletonTarget.hidden = true;
this.errorTarget.hidden = false;
this.element.setAttribute('aria-busy', 'false');
// Un « Presque prêt… » au-dessus du message d'erreur se contredirait.
if (this.hasMessageTarget) {
this.messageTarget.hidden = true;
}
window.lucide?.createIcons?.();
}
sourceWithoutRefresh() {
const current = this.frame.getAttribute('src');
if (!current) {
return null;
}
const url = new URL(current, window.location.href);
url.searchParams.delete('refresh');
url.searchParams.delete('refresh_ts');
return url.pathname + url.search;
}
}
Aller plus loin : l’asynchrone, lorsque les mesures le justifieront
Ces modifications améliorent nettement l’expérience, mais une limite demeure : la génération occupe toujours un worker HTTP jusqu’à la fin du traitement. La déplacer dans une Turbo Frame permet d’afficher le dashboard plus tôt, sans supprimer cette occupation côté serveur.
Une piste serait de confier la génération à un traitement en arrière-plan, avec Symfony Messenger, une file SQS et un worker dédié. La requête HTTP servirait alors à enregistrer la demande et à déclencher le traitement. Il resterait ensuite à informer l’interface lorsque les recommandations sont disponibles, tout en gérant les échecs et les éventuelles demandes en double.
SQS serait donc une pièce de cette architecture, avec un coût supplémentaire de mise en place et d’exploitation. Avant d’engager ce chantier, je souhaite mesurer le comportement de l’application sous charge avec k6 : combien de générations simultanées peut-elle absorber, à partir de quand les requêtes attendent-elles et quel impact cela a-t-il sur le reste du site ?
Au trafic actuel, la saturation des workers n’est plus le problème prioritaire. L’attente devant le squelette reste, elle, bien visible. La prochaine amélioration envisagée consiste donc à afficher les trois contenus dès leur sélection, puis à compléter les cartes avec les explications du LLM. Ces explications dépendent des titres retenus, mais leur rédaction ne devrait pas nécessairement retarder l’affichage des cartes.
C’est ainsi que je fais évoluer Kejja : construire une première réponse, observer ses limites, mesurer puis choisir l’amélioration suivante. L’asynchrone fera l’objet d’un chantier dédié si les résultats le justifient. Pour l’instant, l’objectif reste concret : permettre à l’utilisateur de découvrir plus rapidement ce qu’il pourrait regarder ce soir.
Conclusion
Cette évolution n’a rien de spectaculaire sur le plan technique. Elle illustre pourtant une part essentielle du développement d’un produit : identifier un problème, choisir une réponse adaptée et mesurer ce qu’elle améliore réellement.
Avec Symfony, Twig, Turbo et Stimulus, j’ai pu faire évoluer l’expérience de Kejja en m’appuyant sur les outils déjà présents dans le projet. Le dashboard s’affiche plus rapidement, l’attente est mieux accompagnée et les limites restantes sont identifiées.
Kejja permet déjà de découvrir des films et des séries selon vos goûts. Pour continuer à l’améliorer, le défi consiste autant à consolider les parcours existants qu’à ajouter de nouvelles fonctionnalités. Cela demande du temps, des vérifications et parfois de revenir sur des choix qui semblaient suffisants au départ.
C’est cette approche incrémentale que je souhaite conserver : traiter les points de friction observés, vérifier les résultats et faire évoluer l’architecture lorsque le besoin se précise. Chaque amélioration doit servir la même ambition : vous aider à trouver plus facilement quoi regarder.
Et vous, avez-vous déjà essayé Kejja ? Créez votre compte et découvrez les recommandations qui vous attendent. Vos retours m’aideront à choisir les prochaines améliorations.