Portfolio de développeur junior : 7 erreurs qui coûtent des entretiens

Vous avez mis votre portfolio en ligne, vous avez collé le lien dans vos candidatures, et il ne se passe rien. Avant d'accuser votre code ou votre parcours, il y a une vérification beaucoup plus bête à faire : est-ce que la page fait correctement son travail quand quelqu'un l'ouvre pour la première fois, sur son téléphone, sans y consacrer sa journée ?

Je ne suis pas recruteur et je ne vais pas vous inventer une statistique sur le temps qu'ils passent devant un profil. Ce que je peux faire, c'est autre chose : j'ai fabriqué un portfolio de débutant crédible en y mettant les sept erreurs que je vois le plus souvent, je l'ai ouvert avec les mêmes outils que n'importe quel visiteur, et j'ai mesuré ce que chacune coûte. Puis j'ai corrigé les sept et j'ai tout remesuré.

La version propre de cette page existe déjà ici : c'est celle qu'on construit pas à pas dans l'article sur le portfolio en HTML, CSS et un peu de JS. Aujourd'hui, on regarde l'inverse, et dans l'ordre où le visiteur les rencontre : ce qui casse, et de combien.

Le parcours du visiteur, et où il casse 1. Il ouvre le lien, sur son téléphone 2. Il attend que la page se charge 3. Il lit ce qu'il y a dessus 4. Il veut voir votre code 5. Il veut vous écrire Pas de balise viewport : la page entière est réduite à 0,35 7,03 Mo d'images, soit 6,5 s sur une bonne 4G Gris à 2,17:1, « Projet 1 » et trois Lorem ipsum Deux liens sur trois en 404, filtres tués par une TypeError Formulaire sans destination : le message est perdu en silence

Deux portfolios, un navigateur, un chronomètre

Le banc tient en deux dossiers servis par un petit serveur local : une version « avant » qui cumule les sept erreurs, une version « après » où elles sont corrigées une par une. Les deux sont ouvertes dans Chrome 152.0.7977.76 piloté par Playwright, à 1440 pixels pour le bureau et à 390 pixels en émulation mobile (rapport de pixels 3, mode tactile), avec le réseau bridé par le protocole de débogage de Chrome à 1 125 000 octets par seconde, soit 9 Mbit/s, et 85 millisecondes de latence. C'est une bonne 4G, pas une connexion dégradée : je n'ai pas cherché à charger la barque.

Toutes les valeurs de cet article sortent de ce banc, mesurées le 7 septembre 2026. Un poids, c'est la somme des transferSize de l'API Performance du navigateur, document compris ; une durée, c'est le loadEventEnd du même relevé, donné en médiane de cinq chargements parce qu'une durée seule ne se reproduit jamais à la milliseconde. Les deux faits externes que je cite, le seuil de contraste et la visibilité d'un dépôt privé, sont sourcés et datés du même jour. Et chaque erreur vient avec le bout de code qui vous permet de la chercher sur votre propre page, à coller dans la console du navigateur. Une précision sur le banc : les quatre visuels du portfolio sont des images synthétiques de 3000 par 2000 pixels fabriquées avec ffmpeg, pas de vraies captures de projet ; les poids et les gains de compression que je cite valent pour ces fichiers-là, et un autre contenu compresserait autrement.

Erreur 1 : pas de balise viewport, et le téléphone dézoome à 0,35

C'est la première erreur du parcours, et elle compromet d'emblée la lisibilité de tout le reste. La page « avant » n'a pas de balise viewport dans son head, et son CSS impose une largeur fixe de 1100 pixels au body. Sur un écran de 390 pixels, le navigateur ne rend pas la page illisible par méchanceté : il choisit une largeur de mise en page qui englobe tout le contenu, 1101 pixels au relevé de window.innerWidth, puis il ramène les 1100 pixels du body dans les 390 pixels de l'écran.

<!-- Version « avant » : rien pour dire au téléphone quoi faire -->
<head>
  <meta charset="utf-8">
  <title>Mon portfolio</title>
  <link rel="stylesheet" href="style.css">
</head>

Le facteur de réduction se lit directement dans le navigateur : visualViewport.scale vaut 0,3545, soit 390 divisé par 1100 à l'arrondi près. Autrement dit, votre texte écrit en 16 pixels CSS est ramené à 5,7 pixels à l'échelle d'affichage : sur l'écran du banc, dont le rapport de pixels est de 3, cela fait 17 pixels physiques là où un texte normal en occuperait 48. Il n'y a même pas de défilement horizontal pour signaler le problème, le navigateur a tout ramené à l'échelle, et c'est bien ça le piège : la page a l'air complète, elle est juste minuscule. Le mécanisme de la largeur par défaut et la méthode mobile-first sont détaillés dans l'article sur le responsive et les media queries.

// À coller dans la console, sur votre portfolio, en émulation mobile ou sur un vrai téléphone
console.log({
  metaViewport: document.querySelector('meta[name="viewport"]')?.content ?? "ABSENTE",
  largeurDeMiseEnPage: window.innerWidth,
  facteurDeReduction: visualViewport.scale,
});

Premier réflexe : la balise <meta name="viewport" content="width=device-width, initial-scale=1"> dans le head, et max-width: 1100px à la place de width: 1100px. J'ai mesuré une variante du banc qui ne change que ces deux lignes : le facteur remonte à 1 et le texte redevient lisible, mais l'affaire n'est pas réglée. L'image de tête garde son width: 1100px : elle occupe toujours 1100 pixels dans une fenêtre de 390, soit 710 pixels hors de l'écran, et le contenu de la page s'étale sur 1100 pixels de large.

La version corrigée du banc va donc jusqu'au bout : la balise viewport, les images en width: 100% et height: auto, et les cartes en grille repeat(auto-fit, minmax(260px, 1fr)) plutôt qu'en rangée Flexbox figée. C'est à ce prix que le relevé donne 390 pixels de large, un facteur de 1 et plus rien qui dépasse : l'image de tête mesure alors 358 pixels.

Erreur 2 : 7,03 mégaoctets d'images pour quatre visuels

La page « avant » affiche une capture en tête et une vignette par projet. Quatre images en PNG de 3000 par 2000 pixels, la taille à laquelle sort une capture d'écran non retouchée sur un grand moniteur. Le relevé réseau est sans appel : 7 033 963 octets pour 7 requêtes, le document, la feuille de style, le script et les quatre images, dont 7 029 813 octets pour les seules images. Le HTML, le CSS et le JavaScript pèsent ensemble 4 150 octets, soit six centièmes de pour cent du poids de la page.

Sur mon écran de développeur, en local et sans bridage, la page se charge en 64 millisecondes et tout va bien. Sur le banc mobile à 9 Mbit/s, le loadEventEnd passe à 6 519 millisecondes, les cinq relevés tenant entre 6 512 et 6 528. Six secondes pendant lesquelles le visiteur regarde une page qui se remplit par morceaux. Le premier affichage, lui, arrive à 324 millisecondes : la page a l'air prête bien avant de l'être, ce qui rend l'attente encore plus désagréable.

// Poids transféré de la page, image par image (à lancer après le chargement complet,
// à froid ; une ressource servie par un autre domaine peut masquer sa taille)
const res = performance.getEntriesByType("resource");
const doc = performance.getEntriesByType("navigation")[0].transferSize;
const total = res.reduce((s, r) => s + r.transferSize, 0) + doc;
console.table(
  res
    .filter((r) => r.initiatorType === "img")
    .map((r) => ({ image: r.name.split("/").pop(), octets: r.transferSize })),
);
console.log("total transféré :", total, "octets");

La correction n'a rien de savant : redimensionner à la taille d'affichage, puis compresser. J'ai passé les quatre PNG dans ffmpeg 8.1.2 (scale=1400:-1 pour la capture, scale=612:-1 pour les vignettes, qualité 6), ce qui donne 178 992 octets d'images transférés au lieu de 7 029 813. La page complète tombe à 183 595 octets, soit 38 fois moins, et son chargement mobile à 400 millisecondes. Le choix des dimensions et des attributs qui vont avec est repris dans l'article sur l'insertion d'images.

Erreur 3 : un gris à 2,17:1, sous la barre de l'accessibilité

Le gris clair sur blanc a l'air élégant sur un écran de bureau bien calibré, dans une pièce sans reflet. La page « avant » utilise #b0b0b0 pour tous ses paragraphes. Mesuré depuis la page, le rapport de contraste avec le fond blanc vaut 2,17:1. Le critère 1.4.3 des WCAG 2.2, niveau AA, demande « a contrast ratio of at least 4.5:1 » pour du texte normal et 3:1 pour du grand texte : on est sous les deux seuils, vérifié sur le site du W3C le 7 septembre 2026.

Ce n'est pas qu'une question de norme. Un visiteur qui lit votre page dehors, en plein soleil, sur un téléphone dont l'écran n'est pas au maximum, risque de ne plus rien distinguer. Et pour un poste de développeur web, publier une page qui échoue au critère de contraste des WCAG envoie, à mon sens, un signal que vous ne voulez pas envoyer.

// Contraste d'un élément avec le fond du body (formule WCAG, couleurs opaques)
const luminance = (c) =>
  c
    .map((x) => (x / 255 <= 0.04045 ? x / 255 / 12.92 : ((x / 255 + 0.055) / 1.055) ** 2.4))
    .reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i], 0);
const rgb = (s) => s.match(/\d+/g).slice(0, 3).map(Number);
const contraste = (fg, bg) => {
  const [a, b] = [luminance(rgb(fg)), luminance(rgb(bg))].sort((x, y) => y - x);
  return ((a + 0.05) / (b + 0.05)).toFixed(2);
};
const el = document.querySelector("main p");
console.log(contraste(getComputedStyle(el).color, getComputedStyle(document.body).backgroundColor));

Cet extrait suppose ce que fait la page du banc : un élément qui existe, une couleur de texte opaque et un fond uni posé sur le body. Sur une page qui empile des fonds semi-transparents ou une image derrière le texte, il rend un chiffre qui ne veut rien dire, et il ne remplace de toute façon pas un vrai audit d'accessibilité. Dans la version corrigée, les paragraphes passent en #333 et la ligne d'accroche en #595959 : 12,63:1 et 7,00:1 au même relevé. On garde une nuance de gris pour hiérarchiser, on la garde juste au-dessus du seuil. La façon de manipuler ces valeurs est détaillée dans l'article sur les couleurs et les fonds en CSS.

Erreur 4 : deux liens de projet sur trois tombent en 404

Un portfolio n'existe que pour amener quelqu'un vers votre code. Sur la page « avant », les trois cartes de projet pointent vers https://github.com/votre-pseudo/projet-1, /demo/projet-2 et /projets/snake.html. Testées depuis la page elle-même, les deux adresses internes renvoient un code 404. Quant au lien GitHub, il n'a pas été testé sur le banc, mais elle porte le piège que je vois le plus souvent : le dépôt reste privé. La documentation de GitHub est explicite, « Private repositories are only accessible to you, people you explicitly share access with, and, for organization repositories, certain organization members » (consultée le 7 septembre 2026), autrement dit vous, les personnes à qui vous avez donné accès et, dans une organisation, certains de ses membres. Connecté à votre compte, vous voyez la page. Le visiteur, non.

// Statut réel de tous les liens internes de la page
for (const a of document.querySelectorAll("a[href]")) {
  let cible;
  try {
    cible = new URL(a.href);
  } catch {
    continue; // URL non analysable
  }
  if (cible.origin !== location.origin) continue;
  try {
    const reponse = await fetch(cible.href);
    console.log(reponse.status, a.getAttribute("href"), a.textContent.trim());
  } catch (erreur) {
    console.log("échec réseau", a.getAttribute("href"), erreur.message);
  }
}

La règle que j'applique avant de publier : ouvrir chaque lien du portfolio dans une fenêtre de navigation privée, déconnectée de tout. Ce que vous voyez là est exactement ce que verra le visiteur. Un lien mort vaut moins qu'un projet absent, parce qu'il donne l'impression que vous n'avez pas relu votre propre page. La syntaxe et les pièges des liens sont repris dans l'article sur les liens en HTML.

Erreur 5 : une faute de frappe qui tue tout le JavaScript

La page « avant » a des boutons de filtre au-dessus des projets. Ils sont là, ils réagissent au survol, et ils ne font rien. Le fichier script.js cherche .filtres alors que le HTML écrit class="filters" : querySelector renvoie null, et la ligne suivante lève une exception qui interrompt tout le fichier.

// Version « avant » de script.js : la classe cherchée n'existe pas dans le HTML
const barre = document.querySelector(".filtres");
barre.addEventListener("click", (evt) => {
  const filtre = evt.target.dataset.filtre;
  if (!filtre) return;
  document.querySelectorAll(".carte").forEach((carte) => {
    carte.hidden = filtre !== "tous" && carte.dataset.tech !== filtre;
  });
});

Le navigateur le dit très clairement, à condition d'ouvrir la console : Uncaught TypeError: Cannot read properties of null (reading 'addEventListener'), ligne 3, colonne 7 de script.js. Mesuré au clic sur le bouton « CSS » : 3 cartes visibles avant le clic, 3 cartes visibles après. Une fois le sélecteur corrigé, le même clic laisse 1 carte visible. Rien d'autre n'a changé : dans le script, .filtres est devenu .filters.

La leçon n'est pas « faites moins de fautes de frappe », personne n'y arrive. Elle est : ouvrez la console de votre navigateur sur votre page publiée, au moins une fois, avant de coller le lien dans une candidature. Une exception non attrapée arrête l'exécution du script à cet endroit précis, et tout ce que vous aviez écrit en dessous n'existe plus pour le visiteur, même si ce code est parfait.

Erreur 6 : « Projet 1 », « Votre Nom » et trois Lorem ipsum

Sur la page « avant », le titre principal annonce « Votre Nom », deux cartes sur trois s'appellent « Projet 1 » et « Projet 2 », et trois blocs de Lorem ipsum traînent encore, un dans la présentation et un sous chacune des deux premières cartes. C'est le genre de chose qu'on se promet de remplacer juste avant de publier, et qu'on oublie parce qu'on ne relit plus une page qu'on a regardée deux cents fois.

// Ce qui traîne encore dans votre page
const suspects = [/lorem ipsum/i, /votre nom/i, /projet \d/i, /exemple\.com/i, /à compléter/i, /TODO/];
const texte = document.body.innerText;
console.log(suspects.filter((motif) => motif.test(texte)).map(String));

Le remède demande un peu de courage, pas de technique : donner un vrai nom à chaque projet, écrire deux phrases sur ce qu'il fait et une sur ce qu'il vous a appris. C'est aussi l'endroit où un parcours atypique devient un atout plutôt qu'un trou : je raconte le mien sur la page à propos de ce site, sans école d'informatique, et c'est cette honnêteté qui rend une page crédible. Si vos projets viennent de tutoriels, dites-le, et racontez ce que vous y avez changé.

Erreur 7 : un formulaire de contact qui perd les messages

Celle-là m'a surpris par sa violence. Le formulaire de la page « avant » a trois champs et un bouton, aucun attribut action, aucun attribut name sur les champs. J'ai rempli les trois champs avec un message de recruteur, cliqué sur « Envoyer », et voici ce que le navigateur a fait : il a rechargé la même page à l'adresse http://127.0.0.1:8123/avant/?, avec une chaîne de requête vide, les trois champs remis à zéro et aucun message d'erreur.

Du point de vue de la personne qui vous écrit, tout s'est bien passé : la page a réagi, les champs se sont vidés, elle attend votre réponse. Elle n'arrivera jamais. Un formulaire sans action se soumet à sa propre adresse, et sans attribut name les champs ne sont même pas envoyés, ce que montre la chaîne de requête restée vide. Le rôle de action, de method et des attributs name est détaillé dans l'article sur les formulaires en HTML.

<!-- Le remplacement le plus sûr sur un portfolio statique -->
<p>
  Écrivez-moi à
  <a href="mailto:prenom.nom.dev@example.org">prenom.nom.dev@example.org</a>,
  je réponds sous deux jours.
</p>

Tant que vous n'avez pas un service qui reçoit vraiment les envois, un lien mailto: visible fait mieux le travail qu'un formulaire décoratif : il marche, et il ne ment pas. La page « avant » ne contenait aucun lien mailto: ; la version corrigée en a un, avec l'adresse affichée en clair pour qu'elle reste copiable même si le lien ne s'ouvre pas.

La même page, sept corrections plus tard

Aucune de ces corrections n'est difficile. Prises ensemble, elles changent complètement ce que le visiteur reçoit, et les mesures le disent mieux que moi : la page passe de 7 033 963 à 183 595 octets, à nombre de requêtes identique, et de 6 519 à 400 millisecondes de chargement sur le même réseau mobile émulé, le contraste de 2,17:1 à 12,63:1, les liens de 404 à 200, le filtre de « ne fait rien » à « 3 cartes puis 1 », et le formulaire qui avalait les messages a laissé place à une adresse que le visiteur peut lire et copier.

Avant et après, aux mêmes conditions Poids de la page avant : 7,03 Mo après : 184 Ko Chargement (mobile, 9 Mbit/s) avant : 6,52 s après : 0,40 s Barres à l'échelle. En pointillés : ce qui a disparu.

Est-ce que ces sept erreurs coûtent vraiment des entretiens ? Je n'ai aucune étude à vous citer là-dessus, et je n'en fabriquerai pas. Ce que le banc montre, c'est qu'à chaque étape du parcours le visiteur perd quelque chose de concret : la lisibilité, six secondes, l'accès à votre code, votre adresse. Ma position, assumée : un lien qu'on n'arrive pas à exploiter ne plaide pas pour vous, et il le fait en silence, sans que personne ne vous en informe.

Les vérifications à faire avant d'envoyer le lien

Ouvrez votre portfolio comme un inconnu le ferait : en navigation privée, sur un téléphone, hors de votre réseau. Puis ouvrez la console, lisez les erreurs rouges qu'elle affiche au chargement, et exécutez les quatre diagnostics de cet article : la balise viewport, le poids transféré, le statut des liens, le contraste du texte. Le bloc de code de l'erreur 5, lui, est un contre-exemple : il montre la panne, il ne la cherche pas. Cette lecture et ces quatre relevés couvrent les erreurs 1 à 5, soit cinq des sept.

Les deux qui restent ne demandent que de la relecture : un texte de remplissage oublié, et un formulaire dont personne n'a jamais vérifié qu'il envoyait quelque chose. Le meilleur test tient en une phrase : envoyez-vous un message depuis votre propre formulaire, et attendez de le recevoir. Si rien n'arrive dans votre boîte, remplacez-le par une adresse en clair, aujourd'hui.

Et si vous n'avez pas encore de page à corriger, commencez par la remplir : les projets guidés de ce site, à commencer par le jeu Snake en JavaScript, donnent des cartes toutes prêtes à y poser, avec le code et l'histoire des bugs qui va avec.