Coder un minuteur Pomodoro en JavaScript : setInterval et ses pièges

Un minuteur Pomodoro alterne 25 minutes de travail et 5 minutes de pause, avec une pause longue de 15 minutes toutes les quatre sessions. Sur le papier, c'est le projet JavaScript le plus simple qui soit : un nombre de secondes, un setInterval qui l'enlève une par une, un affichage. C'est aussi le meilleur terrain pour apprendre ce que setInterval ne promet pas, parce qu'un minuteur faux se voit tout de suite.

Nous allons d'abord écrire la version que presque tout le monde écrit en premier, puis la casser de trois façons : un double-clic, un calcul qui bloque la page, un onglet laissé en arrière-plan. Après dix minutes dans un onglet caché, cette version affichait 23:53 au lieu de 15:00. La version finale, ouverte dans la même fenêtre, affichait 15:01, et nous verrons pourquoi elle se remet à l'heure à chaque appel.

Tout le code de cette page a été exécuté le 19 octobre 2026 sous Windows 11, dans Chromium 153.0.8010.12 (le navigateur de Playwright 1.63.0), piloté depuis Node 20.19.4. Si vous débutez avec querySelector et textContent, le guide pour manipuler le DOM en JavaScript donne les bases utilisées ici.

La page : un affichage, trois boutons

Le projet tient en trois fichiers à placer dans un même dossier : index.html, style.css et pomodoro.js. Aucun outil à installer, la page s'ouvre par un double-clic.

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>25:00 · Travail</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main class="minuteur">
    <p id="phase" aria-live="polite">Travail</p>
    <p id="temps" role="timer">25:00</p>
    <div class="boutons">
      <button id="demarrer" type="button">Démarrer</button>
      <button id="pause" type="button">Pause</button>
      <button id="reinitialiser" type="button">Réinitialiser</button>
    </div>
    <p>Sessions de travail terminées : <span id="compteur">0</span></p>
  </main>
  <script src="pomodoro.js"></script>
</body>
</html>

Deux attributs méritent un mot. role="timer" indique aux technologies d'assistance que ce texte est un minuteur ; d'après MDN, ce rôle a une valeur aria-live implicite à off, donc un lecteur d'écran n'annonce pas chaque seconde. À l'inverse, aria-live="polite" sur #phase fait annoncer le changement de phase (« Pause courte ») sans couper ce qui est en cours de lecture.

Le CSS centre le minuteur et utilise font-variant-numeric: tabular-nums : tous les chiffres prennent la même largeur, et l'affichage ne tremble pas quand on passe de 11:00 à 10:59.

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  font-family: system-ui, sans-serif;
  background: #fdf6ec;
  color: #2b2118;
}

.minuteur {
  text-align: center;
  padding: 1rem;
}

#phase {
  font-size: 1.5rem;
  font-weight: 700;
  margin: 0;
}

#temps {
  font-size: clamp(4rem, 20vw, 8rem);
  font-variant-numeric: tabular-nums;
  margin: 0.5rem 0 1.5rem;
}

.boutons {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  justify-content: center;
}

button {
  font: inherit;
  padding: 0.6rem 1.2rem;
  border: 2px solid #2b2118;
  border-radius: 0.5rem;
  background: #ffffff;
  cursor: pointer;
}

La version que tout le monde écrit d'abord

Voici la première idée : un compteur de secondes, décrémenté à chaque appel d'un setInterval réglé sur 1000 millisecondes. Pour l'essayer, enregistrez-la sous naif.js et remplacez pomodoro.js par naif.js dans la balise <script> de la page.

const affichage = document.querySelector("#temps");
let secondes = 25 * 60;
let intervalle = null;

function formater(s) {
  const min = String(Math.floor(s / 60)).padStart(2, "0");
  const sec = String(s % 60).padStart(2, "0");
  return `${min}:${sec}`;
}

function demarrer() {
  intervalle = setInterval(function () {
    secondes = secondes - 1;
    affichage.textContent = formater(secondes);
    if (secondes === 0) {
      clearInterval(intervalle);
    }
  }, 1000);
}

document.querySelector("#demarrer").addEventListener("click", demarrer);

Ouvrez la page, cliquez sur Démarrer : le compte à rebours défile, une seconde à la fois. Tout semble juste. Le défaut est dans l'hypothèse cachée : « un appel égale une seconde ». Rien dans setInterval ne garantit cela, et les trois essais suivants le montrent.

Piège n° 1 : un deuxième clic, un deuxième minuteur

Double-cliquez sur Démarrer. Chaque clic appelle demarrer(), donc crée un nouvel intervalle, et les deux enlèvent une seconde au même compteur. Mesuré avec une horloge simulée et figée (le temps de la page ne bouge que sur ordre du banc de test, donc le résultat est identique à chaque exécution) : trois secondes après le double-clic, l'affichage indique 24:54 au lieu de 24:57. Le minuteur va deux fois trop vite.

La suite est pire. La variable intervalle ne garde que le dernier identifiant renvoyé par setInterval : celui du premier intervalle est perdu. Quand le compteur atteint zéro, clearInterval(intervalle) n'arrête donc qu'un seul des deux minuteurs, et l'autre continue. Le banc relève 00:00 à 12 minutes 30, puis -1:-30 à 13 minutes et -2:-30 à 14 minutes. Ces affichages étranges viennent de formater appliquée à -30 puis -90 secondes : Math.floor(-30 / 60) vaut -1 et -30 % 60 vaut -30. Rien dans la page ne peut plus arrêter ce minuteur, sauf un rechargement.

La correction tient en une ligne au début de demarrer() : si un intervalle existe déjà, on ne fait rien. Elle est dans la version finale plus bas.

Piège n° 2 : setInterval ne rattrape pas les appels manqués

JavaScript s'exécute sur un seul fil dans la page : pendant qu'une fonction calcule, aucun autre code ne tourne, pas même les appels d'un intervalle. MDN le rappelle dans la page de setTimeout() : un délai peut se déclencher plus tard que prévu si la page est occupée par d'autres tâches.

Pour le mesurer, le banc démarre le minuteur, attend 2,5 secondes, puis lance dans la page une boucle qui occupe le fil pendant 5 secondes, comme le ferait un calcul lourd ou un gros traitement de données. Résultat, identique sur cinq essais : 10,5 secondes après le clic, la version naïve affiche 24:54 au lieu de 24:50. Elle a perdu 4 secondes.

Les instants d'appel relevés dans la page expliquent ce chiffre : 1 s, 2 s, puis rien jusqu'à la fin du blocage, un seul appel à 7,5 s, puis 8 s, 9 s et 10 s. Les cinq appels prévus à 3, 4, 5, 6 et 7 secondes n'ont pas été rejoués : Chromium en a exécuté un seul, en retard, puis a repris son rythme. Six appels en dix secondes et demie, donc six secondes décomptées.

Temps réel (secondes)Appels du setInterval naïf fil bloqué 0246810 Naïf : 6 appels, affiche 24:54Final : lit l'horloge, affiche 24:50
Cinq appels manqués pendant le blocage, un seul rattrapé : le compteur naïf retarde de 4 secondes.

La version finale, soumise aux mêmes cinq essais, affiche 24:50 à chaque fois. Elle a pourtant subi le même blocage, mais elle ne compte pas les appels : à chacun d'eux, elle regarde l'heure.

Piège n° 3 : l'onglet en arrière-plan

Un minuteur Pomodoro passe l'essentiel de sa vie dans un onglet qu'on ne regarde pas, et c'est là que les navigateurs économisent le plus. MDN décrit pour Chrome plusieurs niveaux de limitation des délais : dans une page cachée, les minuteurs sont vérifiés une fois par seconde ; et lorsque la page est cachée depuis plus de 5 minutes, silencieuse depuis plus de 30 secondes, sans WebRTC, avec des appels enchaînés au moins 5 fois, ils ne sont plus vérifiés qu'une fois par minute. Le billet de Chrome for Developers qui a introduit cette limitation intensive dans Chrome 88 donne les mêmes conditions.

Pour le mesurer, Playwright ne convenait pas : sur ce banc, une page qu'il pilote restait « visible » même fenêtre réduite. Le banc lance donc Chromium seul et le pilote par son protocole de débogage (CDP). Les deux versions sont ouvertes dans deux onglets d'une même fenêtre, la version finale au premier plan, puis la fenêtre est réduite dans la barre des tâches : les deux pages passent à document.visibilityState === "hidden". Relevés après dix minutes, soit 600 secondes :

Version naïveVersion finale
Affichage à 600 s23:5315:01
Affichage attendu15:0015:00
Appels de l'intervalle67588
Écart entre les derniers appels60 s environ1 s environ

La version naïve n'a décompté que 67 secondes en dix minutes. Un peu après la première minute, ses appels ont commencé à s'espacer : un écart de 14 secondes, puis un appel toutes les 60 secondes jusqu'à la fin. La version finale, appelée en général une fois par seconde au lieu de quatre (avec, elle aussi, douze appels de moins que prévu pendant la deuxième minute : 48 seulement entre 60 et 120 secondes), restait juste à une seconde près ; relue une seconde et demie après la restauration de la fenêtre, vers 601,5 secondes, elle indiquait 14:59, la valeur exacte.

Deux réserves, pour lire ces chiffres correctement. Les deux onglets n'étaient pas dans la même situation : la version naïve était l'onglet d'arrière-plan de la fenêtre réduite, la version finale son onglet actif, et seule la première est passée à un appel par minute. Et ce passage est arrivé bien avant les 5 minutes documentées ; ce banc ne permet pas d'en établir la raison. La leçon, elle, n'en dépend pas : depuis la page, on ne sait jamais combien de fois le navigateur appellera la fonction. Un minuteur qui compte les appels retarde dès que le navigateur en fait moins ; un minuteur qui lit l'heure est juste à chaque appel, quel qu'en soit le rythme.

La version finale : l'heure de fin fait foi

Les trois pièges ont la même racine : la version naïve croit que le nombre d'appels mesure le temps. La correction consiste à ne plus compter. Au démarrage, on calcule une fois l'heure de fin, Date.now() + restantMs ; à chaque appel, on recalcule simplement ce qu'il reste, fin - Date.now(). Un appel en retard ou sauté ne fausse plus rien : le suivant affiche la bonne valeur, quel que soit le temps écoulé entre les deux.

const DUREES = { travail: 25 * 60, pauseCourte: 5 * 60, pauseLongue: 15 * 60 };
const LIBELLES = { travail: "Travail", pauseCourte: "Pause courte", pauseLongue: "Pause longue" };

const affichage = document.querySelector("#temps");
const libelle = document.querySelector("#phase");
const compteur = document.querySelector("#compteur");

let phase = "travail";
let sessionsTerminees = 0;
let restantMs = DUREES.travail * 1000; // temps restant tant que le minuteur est arrêté
let fin = null; // heure de fin en millisecondes, pendant qu'il tourne
let intervalle = null;

function formater(secondes) {
  const min = String(Math.floor(secondes / 60)).padStart(2, "0");
  const sec = String(secondes % 60).padStart(2, "0");
  return `${min}:${sec}`;
}

function afficher(ms) {
  const texte = formater(Math.ceil(ms / 1000));
  affichage.textContent = texte;
  document.title = `${texte} · ${LIBELLES[phase]}`;
}

function tic() {
  const ms = fin - Date.now();
  if (ms <= 0) {
    terminerPhase();
  } else {
    afficher(ms);
  }
}

function demarrer() {
  if (intervalle !== null) return; // déjà lancé : pas de second minuteur
  fin = Date.now() + restantMs;
  intervalle = setInterval(tic, 250);
  tic();
}

function mettreEnPause() {
  if (intervalle === null) return;
  clearInterval(intervalle);
  intervalle = null;
  restantMs = Math.max(0, fin - Date.now());
  afficher(restantMs);
}

function changerDePhase(nouvellePhase) {
  clearInterval(intervalle);
  intervalle = null;
  phase = nouvellePhase;
  libelle.textContent = LIBELLES[phase];
  restantMs = DUREES[phase] * 1000;
  afficher(restantMs);
}

function terminerPhase() {
  if (phase === "travail") {
    sessionsTerminees += 1;
    compteur.textContent = sessionsTerminees;
    changerDePhase(sessionsTerminees % 4 === 0 ? "pauseLongue" : "pauseCourte");
  } else {
    changerDePhase("travail");
  }
}

document.querySelector("#demarrer").addEventListener("click", demarrer);
document.querySelector("#pause").addEventListener("click", mettreEnPause);
document.querySelector("#reinitialiser").addEventListener("click", () => changerDePhase(phase));

Quelques choix de ce code, dans l'ordre où on les rencontre :

Arrondir vers le haut, sinon la dernière seconde disparaît

Le temps restant est en millisecondes ; il faut le convertir en secondes entières pour l'afficher. Math.floor semble naturel, et c'est une erreur. Mesuré avec l'horloge simulée, 250 millisecondes après le départ, il reste 1 499 750 millisecondes : Math.floor affiche déjà 24:59, alors que la première seconde n'est pas finie. À 500 millisecondes de la fin, il affiche 00:00 alors que la session tourne encore.

Math.ceil arrondit vers le haut : 25:00 au départ, 00:01 pendant toute la dernière seconde, et la phase change au premier appel qui suit la fin (au plus 250 millisecondes plus tard quand l'onglet est au premier plan). Avec l'horloge simulée, le banc relève alors « Pause courte » et 05:00.

Pause, reprise et enchaînement des phases

Le bouton Pause arrête l'intervalle et mémorise le temps restant ; Démarrer recalcule une nouvelle heure de fin à partir de lui. Le banc a vérifié l'enchaînement : 24:50 après 10 secondes, toujours 24:50 après une minute de pause, 24:45 cinq secondes après la reprise. Réinitialiser remet la phase en cours à sa durée complète (25:00, et rien ne bouge ensuite).

À la fin d'une phase, terminerPhase() choisit la suivante et arrête le minuteur : vous relancez vous-même, ce qui laisse le temps de finir une phrase avant la pause. Sur un cycle complet de quatre sessions de travail, le banc obtient la séquence attendue : trois fois « Pause courte » à 05:00, puis « Pause longue » à 15:00 après la quatrième session, avec un compteur de sessions qui passe de 1 à 4 et un titre d'onglet à jour à chaque étape (par exemple « 15:00 · Pause longue »).

Tester 25 minutes en une trentaine de secondes

Attendre 25 minutes pour vérifier un minuteur n'est pas une méthode. Playwright, un outil qui pilote un vrai navigateur depuis un script, sait remplacer l'horloge de la page. Attention, page.clock.install() seul ne suffit pas : il installe une horloge pilotable, mais le temps continue de s'écouler normalement. C'est page.clock.pauseAt() qui le fige à une date donnée ; ensuite, page.clock.runFor() le fait avancer d'autant de millisecondes qu'on veut, en déclenchant au passage tous les appels d'intervalle prévus. Sur ce banc, une fois la pause posée, deux secondes d'attente réelle n'ont pas fait avancer l'horloge de la page d'une seule milliseconde. Dans le dossier du projet, installez-le avec npm install playwright puis npx playwright install chromium, enregistrez ce fichier sous test-pomodoro.mjs et lancez node test-pomodoro.mjs depuis ce même dossier.

import { chromium } from "playwright";
import assert from "node:assert/strict";
import { pathToFileURL } from "node:url";

const MINUTE = 60_000;
const navigateur = await chromium.launch();
const page = await navigateur.newPage();
await page.clock.install({ time: new Date("2026-10-19T09:00:00") });
await page.goto(pathToFileURL("index.html").href);
await page.clock.pauseAt(new Date("2026-10-19T09:00:05")); // dès maintenant, le temps n'avance que sur ordre

// Deux clics sur Démarrer ne doivent pas faire tourner le minuteur deux fois plus vite.
await page.click("#demarrer");
await page.click("#demarrer");
await page.clock.runFor(3000);
assert.equal(await page.textContent("#temps"), "24:57");

// Fin de la première session : 25 minutes simulées, sans les attendre.
await page.clock.runFor(25 * MINUTE - 3000);
assert.equal(await page.textContent("#phase"), "Pause courte");
assert.equal(await page.textContent("#temps"), "05:00");
assert.equal(await page.title(), "05:00 · Pause courte");

console.log("Tous les tests passent.");
await navigateur.close();

Sur la machine du banc, le test affiche « Tous les tests passent. » en 29 secondes : simuler 25 minutes, c'est quand même exécuter les 6 000 appels d'un intervalle de 250 millisecondes. Lancé contre la version naïve, il échoue dès la première vérification, avec actual: '24:54', ce qui prouve qu'il sait repérer le piège n° 1. Le temps réel reste indispensable pour les pièges n° 2 et 3, que l'horloge simulée ne reproduit pas : c'est pourquoi le banc les a mesurés à part, en temps réel.

Trois extensions pour aller plus loin

De la plus simple à la plus délicate : rendre les durées réglables par un formulaire ; jouer un son à la fin d'une phase ; enfin, mémoriser fin dans localStorage pour qu'un rechargement de la page ne perde pas la session en cours. Cette dernière est la meilleure preuve que vous avez compris l'article : une heure de fin se sauvegarde et se relit, un compteur d'appels ne se rattrape pas.

Si vous préférez un projet du même format avec une logique de jeu, le morpion en JavaScript avec une IA imbattable sépare lui aussi les règles de l'affichage et vérifie ses résultats par un banc.

D'où viennent les règles de limitation citées

Pages consultées le 19 octobre 2026.