Un monitoring discret
Ce script analyse les performances de chargement sans polluer l'interface. Par défaut silencieux, il s'active via la console (ou un mini script) et affiche un badge flottant repositionnable avec les métriques essentielles.
Les Core Web Vitals sont mesurés via les API natives :
- Performance Observer pour LCP, INP et CLS
- Navigation Timing pour TTFB et DOM
- Le TBT (Total Blocking Time) agrège les tâches longues dépassant 50ms.
- Une heuristique TTI personnalisée complète l'analyse d'interactivité.
- Sur Chromium (Chrome/Edge) la consommation mémoire Javascript est également remontée.
Dans tous les cas, ces mesures sont indicatives.
Pour la mémoire consommée, ça permet de s'assurer que le GC (Garbage Collector) fait bien son travail et que les fuites sont limitées.
À surveiller :
- Si le plancher passe de 50→60→70 MB sur plusieurs cycles = fuite.
- Si ça reste entre 50-90 MB même après 20 navigations = ça roule.
L'analyse de la mémoire calcule :
- Le code applicatif
- Toutes les librairies tierces (frameworks, analytics, widgets…)
- Le DOM JavaScript (représentation mémoire des objets DOM)
- Tous les event listeners, closures, variables globales…
C'est une métrique globale de l'onglet, pas isolée par script.
D'où son intérêt pour détecter une fuite mémoire générale, mais impossible de savoir quel script est le coupable juste avec ça.
Pour isoler le code spécifiquement, il faut passer par les DevTools Memory Profiler avec des snapshots avant/après l'exécution du code.
La console
En complément du badge, la console expose des données déjà accessibles par l'onglet réseau.
- DNS Lookup
- TCP Connection
- DOM Interactive
- DOM Complete
- DOMContentLoaded
Chaque métrique compare sa valeur aux seuils recommandés.
Les long tasks sont tracées avec leur timing et impact de blocage.
Badge
Un joli badge flottant !
Le badge s'ancre en bas à droite au premier lancement. Il est draggable sur toute la fenêtre. Sa position et son état ouvert/fermé persistent.
Les couleurs signalent visuellement le respect des seuils.
L'activation se fait en une ligne dans la console :
showMetrics() // Active et recharge
hideMetrics() // Désactive et recharge
Les observers tournent en continu et mettent à jour le badge toutes les 500ms pendant 10 secondes, capturant les métriques tardives comme LCP ou INP.
Intégration et usage
Le script
Simple : à installer en bas de page, idéalement en environnement de QA, UAT ou pré-prod uniquement.
Taille brute : 14,73 kilo-octets (~4,37 kilo-octets gzip)
(() => {
'use strict';
/**
* Vérifie si les métriques de performance doivent être affichées
* Active avec : sessionStorage.setItem('showMetrics', 'true')
* Désactive avec: sessionStorage.removeItem('showMetrics')
* @returns {boolean}
*/
const shouldShowMetrics = () => sessionStorage.getItem('showMetrics') === 'true';
/**
* Collecte les métriques synchrones (Navigation Timing + Paint)
* Les observers asynchrones sont attachés séparément via attachObservers()
* @returns {Object|null}
*/
const analyzePerformance = () => {
const perf = performance.getEntriesByType('navigation')[0];
if (!perf) {
console.warn('Performance Navigation Timing non disponible');
return null;
}
const paint = performance.getEntriesByType('paint');
return {
// Connexion
dns: Math.round(perf.domainLookupEnd - perf.domainLookupStart),
tcp: Math.round(perf.connectEnd - perf.connectStart),
ssl: perf.secureConnectionStart > 0 ? Math.round(perf.connectEnd - perf.secureConnectionStart) : 0,
// Serveur
ttfb: Math.round(perf.responseStart - perf.requestStart),
download: Math.round(perf.responseEnd - perf.responseStart),
// DOM
domInteractive: Math.round(perf.domInteractive),
domComplete: Math.round(perf.domComplete),
domContentLoaded: Math.round(perf.domContentLoadedEventEnd - perf.domContentLoadedEventStart),
loadComplete: Math.round(perf.loadEventEnd - perf.fetchStart),
// Paint
fcp: paint.find(p => p.name === 'first-contentful-paint')?.startTime ?? 0,
// Transfert
transferSize: perf.transferSize,
encodedBodySize: perf.encodedBodySize,
decodedBodySize: perf.decodedBodySize,
compressionRatio: perf.encodedBodySize > 0
? Math.round((1 - perf.encodedBodySize / perf.decodedBodySize) * 100)
: 0,
// Mémoire
memory: performance.memory ? {
usedJSHeapSize: performance.memory.usedJSHeapSize,
totalJSHeapSize: performance.memory.totalJSHeapSize,
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit
} : null,
// Async — alimentés par attachObservers()
lcp: 0, tbt: 0, fid: 0, inp: 0, cls: 0,
customTTI: 0,
longTasks: []
};
};
/**
* Attache les PerformanceObserver sur l'objet metrics
* N'est appelé que si le badge est actif (shouldShowMetrics)
* @param {Object} metrics
*/
const attachObservers = (metrics) => {
if (!('PerformanceObserver' in window)) return;
new PerformanceObserver(list => {
metrics.lcp = Math.round(list.getEntries().at(-1).startTime);
}).observe({type: 'largest-contentful-paint', buffered: true});
new PerformanceObserver(list => {
list.getEntries().forEach(entry => {
const blocking = Math.max(0, entry.duration - 50);
metrics.tbt += blocking;
metrics.longTasks.push({
start: Math.round(entry.startTime),
duration: Math.round(entry.duration),
blocking: Math.round(blocking)
});
});
}).observe({entryTypes: ['longtask']});
new PerformanceObserver(list => {
list.getEntries().forEach(entry => {
metrics.fid = Math.round(entry.processingStart - entry.startTime);
});
}).observe({type: 'first-input', buffered: true});
new PerformanceObserver(list => {
list.getEntries().forEach(entry => {
const total = (entry.processingStart - entry.startTime)
+ (entry.processingEnd - entry.processingStart)
+ (entry.startTime + entry.duration - entry.processingEnd);
if (total > metrics.inp) metrics.inp = Math.round(total);
});
}).observe({type: 'event', buffered: true, durationThreshold: 16});
new PerformanceObserver(list => {
list.getEntries().forEach(entry => {
if (!entry.hadRecentInput) metrics.cls += entry.value;
});
}).observe({type: 'layout-shift', buffered: true});
// Custom TTI : dernier long task OU domInteractive si aucune long task
window.addEventListener('load', () => {
setTimeout(() => {
const last = metrics.longTasks.at(-1);
metrics.customTTI = last
? Math.round(last.start + last.duration)
: Math.round(performance.getEntriesByType('navigation')[0].domInteractive);
}, 5000);
});
};
/**
* Retourne la couleur de statut selon le seuil
* @param {number} value
* @param {number} threshold
* @returns {string} Couleur CSS
*/
const getStatus = (value, threshold) => value > threshold ? 'coral' : 'greenyellow';
/**
* Retourne une chaîne complétée par la gauche
* @param {number|string} value
* @param {number} [n=5]
* @param {string} [pad=' ']
* @returns {string}
*/
const padLeft = (value, n = 5, pad = ' ') => value.toString().padStart(n, pad);
Storage.prototype.removeItems ??= function (...keys) {
keys.forEach(key => this.removeItem(key));
};
/**
* Affiche un badge flottant avec les métriques de performance
* Instancie aussi analyzePerformance() et attachObservers()
* N'est appelé que si shouldShowMetrics() est vrai
*/
const createMetricsBadge = () => {
window.metrics = analyzePerformance();
attachObservers(window.metrics);
const badge = document.createElement('details');
badge.id = 'widget-metrics';
badge.role = 'group';
if (!sessionStorage.getItem('metricsBadgeCollapsed')) {
badge.open = true;
sessionStorage.setItem('metricsBadgeCollapsed', 'false');
}
/* CSS : depuis le sessionStorage ou génération + mise en cache */
const css = sessionStorage.getItem('metricsWidgetCSS') ?? (() => {
const styles = `
#widget-metrics summary::-webkit-details-marker,
#widget-metrics summary::marker {
display: none;
}
#widget-metrics {
background: rgba(0, 0, 0, 0.9);
border-radius: 0.5rem;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.2) inset, 0 0 8px 0 rgb(0, 0, 0);
color: rgb(255,255,255);
cursor: grab;
font-family: monospace;
font-size: 0.8125rem;
top: calc(var(--sticky-top, 3.125rem) + var(--half-gutter, 1.25rem));
left: var(--fixed-position, 2.5rem);
padding: 0.3125rem;
position: fixed;
user-select: none;
min-width: 18.75rem;
& summary {
color: inherit;
font-weight: bold;
display: block;
padding-left: 0.3125rem;
padding-right: 2rem;
position: relative;
line-height: 2rem;
user-select: none;
&::after {
content: '▼';
position: absolute;
right: 0.625rem;
}
}
&[open] {
summary::after {
content: '▲';
}
}
& dl {
border-top: solid 1px rgba(255, 255, 255, 0.2);
display: grid;
grid-template-columns: auto 1fr;
gap: 0 0.25rem;
margin-top: 0.3125rem;
& dt {
text-align: right;
&:after {
content: "\\A0:" / "";
}
}
& dd {
white-space: no-wrap;
}
& + div {
margin-top: 0.3125rem;
padding-top: 0.3125rem;
border-top: solid 1px rgba(255, 255, 255, 0.2);
}
}
& button {
all: unset;
@media (any-pointer: fine) {
cursor: pointer;
}
color: rgb(137,240,91);
line-height: 1.5rem;
height: 1.5rem;
&:before {
content: '\\00D7';
font-size: 2em;
line-height: inherit;
display: inline-block;
margin: 0 0.3125rem;
vertical-align: sub;
}
}
}`;
sessionStorage.setItem('metricsWidgetCSS', styles);
return styles;
})();
document.head.appendChild(Object.assign(document.createElement('style'), {
id: 'dynamic-widget-metrics',
textContent: css
}));
badge.innerHTML = `<summary>Chargement...</summary>`;
badge.style.zIndex = `${window.getMaxZIndex?.(document.body, true) ?? 1000}`;
badge.addEventListener('click', (e) => {
if (e.target.id === 'hide-metrics-btn') hideMetrics();
});
document.body.appendChild(badge);
badge.offsetHeight; /* force reflow */
badge.addEventListener('toggle', () => {
sessionStorage.setItem('metricsBadgeCollapsed', badge.open ? 'false' : 'true');
});
if (sessionStorage.getItem('metricsBadgeCollapsed') === 'false') badge.setAttribute('open', '');
const saved = sessionStorage.getItem('metricsBadgePosition');
if (saved) {
const {left, top} = JSON.parse(saved);
badge.style.top = `${top}`;
badge.style.left = `${left}`;
badge.style.right = `auto`;
}
let isDragging = false;
let currentX, currentY, initialX, initialY;
/**
* Démarre le drag
* @param {MouseEvent|TouchEvent} e
*/
const dragStart = (e) => {
if (e.target.closest('summary')) return;
e.preventDefault();
isDragging = true;
badge.style.cursor = 'grabbing';
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
initialX = clientX - badge.offsetLeft;
initialY = clientY - badge.offsetTop;
};
/**
* Gère le déplacement
* @param {MouseEvent|TouchEvent} e
*/
const dragMove = (e) => {
if (!isDragging) return;
e.preventDefault();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
currentX = clientX - initialX;
currentY = clientY - initialY;
badge.style.inset = `${currentY}px auto auto ${currentX}px`;
};
/** Termine le drag et sauvegarde la position */
const dragEnd = () => {
if (isDragging && currentX !== undefined) {
try {
sessionStorage.setItem('metricsBadgePosition', JSON.stringify({
left: currentX + 'px',
top: currentY + 'px'
}));
} catch (_) {
}
}
isDragging = false;
currentX = currentY = undefined;
badge.style.cursor = 'grab';
};
badge.addEventListener('mousedown', dragStart);
document.addEventListener('mousemove', dragMove);
document.addEventListener('mouseup', dragEnd);
badge.addEventListener('touchstart', dragStart, {passive: false});
document.addEventListener('touchmove', dragMove, {passive: false});
document.addEventListener('touchend', dragEnd);
new ResizeObserver(() => {
const {right, bottom} = badge.getBoundingClientRect();
const {innerWidth, innerHeight} = window;
if (right > innerWidth) badge.style.left = `${Math.max(0, badge.offsetLeft - (right - innerWidth))}px`;
if (bottom > innerHeight) badge.style.top = `${Math.max(0, badge.offsetTop - (bottom - innerHeight))}px`;
}).observe(document.documentElement);
/** Met à jour le contenu du badge avec les métriques courantes */
const updateBadge = () => {
const m = window.metrics;
if (!m) return;
document.getElementById('widget-metrics').innerHTML = `
<summary>Performances</summary>
<dl>
<dt>CLS</dt>
<dd>
<strong style="color:${getStatus(m.cls, 0.1)};">${padLeft(m.cls.toFixed(m.cls === 0 ? 0 : 4), 5)}</strong>
</dd>
<dt>TTFB</dt>
<dd>
<strong style="color:${getStatus(m.ttfb, 600)};">${padLeft(m.ttfb, 5)}ms</strong>
</dd>
<dt>FCP</dt>
<dd>
<strong style="color:${getStatus(m.fcp, 1800)};">${padLeft(Math.round(m.fcp), 5)}ms</strong>
</dd>
${m.lcp > 0
? `<dt>LCP</dt><dd><strong style="color:${getStatus(m.lcp, 2500)};">${padLeft(m.lcp, 5)}ms</strong></dd>`
: ''
}
<dt>TBT</dt>
<dd>
<strong style="color:${getStatus(m.tbt, 300)};">${padLeft(Math.round(m.tbt), 5)}ms</strong>
</dd>
${m.customTTI > 0
? `<dt>TTI</dt><dd><strong style="color:${getStatus(m.customTTI, 3800)};">${padLeft(m.customTTI, 5)}ms</strong></dd>`
: ''
}
${m.fid > 0
? `<dt>FID</dt><dd><strong style="color:${getStatus(m.fid, 100)};">${padLeft(m.fid, 5)}ms</strong></dd>`
: ''
}
${m.inp > 0
? `<dt>INP</dt><dd><strong style="color:${getStatus(m.inp, 200)};">${padLeft(m.inp, 5)}ms</strong></dd>`
: ''
}
<dt>Mémoire</dt>
<dd>
${m.memory
? `<strong style="color:${getStatus(m.memory.usedJSHeapSize / 1048576, 70)};">${padLeft((m.memory.usedJSHeapSize / 1048576).toFixed(0), 5)} Mo</strong>`
: 'N/A'
}
</dd>
<dt>Document</dt>
<dd>
gzip <strong>${padLeft((m.transferSize / 1024).toFixed(1), 5)}KB</strong><br>
brut <strong>${padLeft((m.decodedBodySize / 1024).toFixed(1), 5)}KB</strong><br>
Compression ${m.compressionRatio}%
</dd>
<dt>DNS lookup</dt>
<dd><strong style="color:${getStatus(m.dns, 100)};">${padLeft(m.dns, 5)}ms</strong></dd>
<dt>TCP</dt>
<dd><strong style="color:${getStatus(m.tcp, 100)};">${padLeft(m.tcp, 5)}ms</strong></dd>
${m.ssl > 0
? `<dt>SSL</dt><dd><strong style="color:${getStatus(m.ssl, 100)};">${padLeft(m.ssl, 5)}ms</strong></dd>`
: ''
}
<dt>DOM interactive</dt>
<dd><strong style="color:${getStatus(m.domInteractive, 1500)};">${padLeft(m.domInteractive, 5)}ms</strong></dd>
<dt>DOMContentLoaded</dt>
<dd><strong style="color:${getStatus(m.domContentLoaded, 500)};">${padLeft(m.domContentLoaded, 5)}ms</strong></dd>
<dt>Dom Complete</dt>
<dd><strong style="color:${getStatus(m.loadComplete, 4000)};">${padLeft(m.loadComplete, 5)}ms</strong></dd>
${m.longTasks.length > 0
? `<dt>Long tasks</dt><dd><strong>${padLeft(m.longTasks.length, 5)}</strong></dd>`
: ''
}
</dl>
<div>
<button type="button" id="hide-metrics-btn">Désactiver</button>
</div>
`;
};
setTimeout(updateBadge, 200);
const updateInterval = setInterval(() => {
updateBadge();
if (window.metrics?.customTTI > 0) clearInterval(updateInterval);
}, 500);
setTimeout(() => clearInterval(updateInterval), 10000);
};
/**
* Active l'affichage des métriques et recharge la page
*/
window.showMetrics = () => {
sessionStorage.setItem('showMetrics', 'true');
document.location.reload();
};
/**
* Désactive l'affichage des métriques et recharge la page
*/
window.hideMetrics = () => {
sessionStorage.removeItems('showMetrics', 'metricsBadgePosition', 'metricsBadgeCollapsed', 'metricsWidgetCSS');
document.location.reload();
};
/* Point d'entrée unique : tout est conditionné à shouldShowMetrics() */
if (shouldShowMetrics()) {
document.readyState === 'complete'
? createMetricsBadge()
: window.addEventListener('load', () => setTimeout(createMetricsBadge, 150));
}
})();
Télécharger kortic-cwv-metrics.js
Aucune configuration nécessaire. Le script s'exécute en IIFE, expose window.metrics avec les données brutes et les fonctions showMetrics()/hideMetrics() pour basculer l'affichage.
Ouvrez la console de votre navigateur et saisissez showMetrics(), application immédiate sur Kortic.
La flemme ? Cliquer en dessous… on est sympa ici !
La page se recharge automatiquement.
Le code d'activation C'est cadeau : le bout de HTML pour activer/désactiver sans passer par la console.
<!-- un input checkbox pour activer/désactiver -->
<input type="checkbox" id="toggleMetricsTest">
<label for="toggleMetricsTest" class="switch-marker">Activer les métriques</label>
<!-- et un mini JS -->
<script>
(() => {
document.addEventListener('DOMContentLoaded', () => {
const checkbox = document.getElementById('toggleMetricsTest');
checkbox.checked = sessionStorage.getItem('showMetrics') === 'true';
checkbox.addEventListener('change', () => {
checkbox.checked
? sessionStorage.setItem('showMetrics', 'true')
: sessionStorage.removeItem('showMetrics');
document.location.reload();
});
});
})();
</script
Conclusion
Compatible avec tous les navigateurs récents supportant Performance Observer.
Les métriques manquantes (navigateurs anciens) sont simplement ignorées.
Idéal pour auditer rapidement une page, comparer des versions, ou laisser activé en dev pour surveiller les régressions. Le badge ne gêne jamais la navigation et se retire d'un clic sur avec un bouton.
À votre console ! La DevTools, pas la DualSense !