~10 min de lecture

Upload de fichiers en Angular : les 4 pièges, du 400 inexpliqué à la barre morte

TL;DR

Un upload avec barre de progression, c'est quatre pièges qui s'empilent. Un Content-Type posé à la main qui casse le boundary du FormData (le séparateur entre les parties du corps, généré par le navigateur) : erreur immédiate, 400 ou 500 selon le framework. Un HttpClient branché sur fetch, sa couche de transport par défaut depuis Angular 22 : fetch n'expose pas de progression d'envoi, et demander reportUploadProgress fait échouer la requête avec NG02824. Un reportUploadProgress sans observe: 'events', qui produit des événements que ton subscribe ne reçoit jamais. Et une annulation jamais câblée, alors qu'un unsubscribe() interrompt la requête.

Le ticket a l'air inoffensif : "l'utilisateur peut uploader son avatar, avec une barre de progression". Tu as déjà fait cent requêtes HTTP, un POST avec un FormData ne va pas te résister. Deux heures plus tard, ta barre reste à zéro avant de passer directement à "terminé", ou pire : le serveur renvoie un 400 que personne ne comprend alors que le même appel passe nickel dans Postman.

L'upload de fichiers, c'est le coin de HttpClient où des réglages par défaut, raisonnables partout ailleurs, se retournent contre toi. Angular 22 change même la donne : fetch devient le transport par défaut, et la nouvelle option reportUploadProgress y échoue avec une erreur explicite là où l'ancienne, reportProgress, se taisait. Voici les quatre pièges, dans l'ordre où un projet v22 les rencontre.


Piège 1 : le Content-Type que tu n'aurais jamais dû écrire

Le réflexe vient des tutos : "l'endpoint attend du multipart/form-data, donc je pose le header". Le code qu'on croise dans une PR sur deux :

// upload.ts - WRONG
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Injectable({ providedIn: 'root' })
export class AvatarUpload {
  private readonly http = inject(HttpClient);

  upload(file: File) {
    const body = new FormData();
    body.append('avatar', file);

    return this.http.post('/api/avatar', body, {
      headers: { 'Content-Type': 'multipart/form-data' }, // <- le bug
    });
  }
}

Résultat : le serveur rejette la requête, en 400 ou en 500 selon le framework (Express + multer répond 500 "Multipart: Boundary not found"). Parce qu'un vrai header multipart ressemble à ça :

Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryd1zAeZ4GHCRJ78je

Le boundary est la chaîne qui sépare les parties du corps, générée par le navigateur au moment de sérialiser le FormData. Posé à la main, ton header part sans boundary : le serveur ne peut pas découper le corps, il te jette.

Ce que peu de devs savent : sans Content-Type explicite, la détection automatique du type de contenu de HttpClient renvoie délibérément null pour un FormData. Aucun header n'est posé côté Angular : le navigateur le pose lui-même, boundary inclus. Le fix est une suppression :

// upload.ts - RIGHT
upload(file: File) {
  const body = new FormData();
  body.append('avatar', file);

  return this.http.post('/api/avatar', body); // zéro header, c'est voulu
}

Règle simple : avec un FormData, le Content-Type ne te regarde pas.


Piège 2 : fetch, le transport par défaut, ne sait pas mesurer un envoi

Deuxième étape : la barre de progression. Tu ajoutes l'option dédiée... et ton upload plante avec une erreur jamais vue.

D'abord le décor. HttpClient ne parle pas au réseau lui-même : il délègue l'envoi à un backend, la couche de transport qui exécute réellement la requête. Angular en fournit deux pour les requêtes courantes : HttpXhrBackend, basé sur XMLHttpRequest, et FetchBackend, basé sur fetch. Or fetch n'a pas d'équivalent au xhr.upload.addEventListener('progress', ...) de XMLHttpRequest : il n'expose aucun événement de progression d'envoi. Le contournement qui consiste à envoyer le corps sous forme de flux (ReadableStream) et à compter les octets au passage ne marche pas partout, et Angular ne l'utilise pas. La progression de téléchargement, elle, marche sur les deux backends (fetch lit le flux de la réponse).

Là où ça devient une question de version :

  • Angular 17 à 21 : XHR est le backend par défaut, withFetch() un opt-in que beaucoup d'équipes ont activé, notamment pour le SSR. Avec reportProgress: true sur fetch, les événements UploadProgress ne sont jamais émis. Pas d'erreur, pas de warning : une barre morte, et toi qui relis ton code en boucle.
  • Angular 22 : FetchBackend devient le backend par défaut et withFetch() est déprécié (les autres changements HTTP de la v22). Mais la nouvelle option reportUploadProgress ne te laisse plus dans le noir : sur fetch, elle fait échouer la requête avec une erreur franche, NG02824.

L'erreur arrive dans le canal error de ton subscribe, sous la forme d'un HttpErrorResponse de status 0, qui porte l'exception d'origine (un RuntimeError) dans sa propriété error. En production, seul le code NG02824 reste. En dev, le message donne le remède en toutes lettres :

NG02824: The FetchBackend does not support upload progress reporting. Please use `withXhr()` on your `provideHttpClient()` configuration if you want to report upload progress.

Nuance avant de paniquer pour ta migration : ng update vers la v22 inclut un schematic (une migration automatique qui réécrit ton code, comme à chaque montée de version) qui ajoute withXhr() aux provideHttpClient() sans withFetch() ni withXhr(), pour préserver le comportement existant. NG02824 touche donc d'abord trois profils : une app créée en v22, une app déjà passée à withFetch(), et une app migrée sans les schematics. Plus un quatrième, sournois : l'app qui injecte HttpClient sans jamais appeler provideHttpClient(), un cas possible depuis la v21, où HttpClient est fourni par défaut. Le schematic n'y trouve aucun appel à compléter, donc n'ajoute rien : cette app bascule sur fetch en v22. Et encore : NG02824 n'est levée qu'avec la nouvelle option. Un code migré qui garde reportProgress ne crie pas : sa barre meurt en silence (on y revient plus bas).

Tu veux une vraie barre de progression d'upload ? Il faut demander le backend XHR :

// app.config.ts (Angular 22)
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withXhr } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(withXhr()),
  ],
};

Trois précautions avant de copier-coller :

  1. Posé à la racine, le choix vaut pour toutes les requêtes. Tu peux le limiter à une branche : un provideHttpClient(withXhr()) dans les providers d'une route crée un client XHR pour cette branche, le reste garde fetch. Deux chausse-trappes : le client de branche ne reprend pas les interceptors de la racine (redéclare-les dans le même appel, provideHttpClient(withXhr(), withInterceptors([...])), sinon ton upload part sans header d'auth), et un service providedIn: 'root' garde le client racine.
  2. La doc est brutale sur le SSR : pas de withXhr() côté serveur. Le support XHR serveur est déprécié, retrait prévu en Angular 23, et la bibliothèque xhr2 sous-jacente a des problèmes de sécurité (header Authorization transféré sur une redirection cross-origin, boucles de redirection). On voit plus bas comment garder fetch côté serveur, car ce n'est pas automatique.
  3. Si la progression n'est qu'un nice-to-have, l'alternative honnête est de rester sur fetch et d'afficher un état indéterminé (spinner, ou barre sans valeur).

Dernier détail vicieux : l'ancienne option reportProgress, dépréciée en v22 au profit du duo reportUploadProgress / reportDownloadProgress, est toujours acceptée. Sur le backend fetch, elle ne lève rien du tout : tu reçois Sent, ResponseHeader, les DownloadProgress, la Response, et jamais un seul UploadProgress. Le garde-fou NG02824 ne protège que la nouvelle option. Si tu migres un vieux code d'upload vers la v22, renomme l'option : sur XHR, l'upload continue de marcher (et si ton code lisait aussi les DownloadProgress ou le ResponseHeader, ajoute reportDownloadProgress: true), et sur fetch tu échanges au moins le silence contre une erreur bruyante qui, en dev, nomme le remède.


Piège 3 : produire des événements sans jamais les recevoir

Te voilà sur XHR. Tu écris la version logique :

// WRONG : l'option est là, mais ton subscribe ne voit rien
return this.http.post('/api/avatar', body, {
  reportUploadProgress: true,
});

Ton subscribe reçoit... la réponse finale, et rien d'autre. Aucun événement de progression, aucune erreur, aucun warning.

C'est que reportUploadProgress ne change pas ce que l'observable émet : elle demande au backend de produire les événements de progression. Pour les recevoir, il faut aussi basculer l'observable en mode flux d'événements avec observe: 'events'. Les deux options sont indépendantes et, pour que ton subscribe reçoive la progression via post()/get(), il faut les deux :

  • reportUploadProgress: true seul : sur XHR, les événements sont bien produits (un interceptor les voit passer), puis filtrés en interne pour ne te livrer que la réponse. Ils sont opt-in parce qu'ils ne sont pas gratuits : la doc précise que chaque événement déclenche une change detection. Ça vaut avec zone.js. En zoneless, les événements de progression ne planifient rien d'eux-mêmes : c'est le set() de ton handler qui planifie le rendu.
  • observe: 'events' seul : tu reçois bien un flux d'événements (Sent, Response...), mais sans les événements de progression.

La version qui marche :

import { HttpEvent } from '@angular/common/http';
import { Observable } from 'rxjs';

upload(file: File): Observable<HttpEvent<AvatarResponse>> {
  const body = new FormData();
  body.append('avatar', file);

  return this.http.post<AvatarResponse>('/api/avatar', body, {
    reportUploadProgress: true,
    observe: 'events', // <- sans ça, ton subscribe ne voit que la réponse
  });
}

En v17-21, remplace reportUploadProgress par reportProgress dans les exemples et retire withXhr() : ni l'option ni withXhr() n'existent avant la v22, et XHR y est de toute façon le backend par défaut (si tu étais passé à withFetch(), retire-le aussi). Le piège observe: 'events', lui, est identique.

Côté consommation, tu filtres par type. Deux détails : total est optionnel (total?: number), donc vérifie qu'il est défini avant de calculer le pourcentage ; et l'événement final Response arrive dans le même flux :

import { HttpEventType } from '@angular/common/http';

private readonly avatarUpload = inject(AvatarUpload);

// ...
this.avatarUpload.upload(file).subscribe((event) => {
  switch (event.type) {
    case HttpEventType.UploadProgress:
      if (event.total !== undefined) {
        this.progress.set(Math.round((100 * event.loaded) / event.total));
      }
      break;
    case HttpEventType.Response:
      this.state.set({ status: 'done' });
      break;
  }
});

Piège 4 : l'upload qu'on ne peut pas annuler

Un upload de 80 Mo, c'est vite une minute pendant laquelle ton utilisateur voudra annuler, changer de fichier ou quitter la page. Si ton subscribe part dans la nature sans Subscription conservée ni opérateur de coupure (takeUntil, takeUntilDestroyed), tu n'as plus de levier : la requête consommera la bande passante jusqu'au bout.

Bonne nouvelle : l'annulation est déjà câblée dans HttpClient. Les observables HTTP sont froids (rien ne part tant que personne ne s'abonne), et se désabonner déclenche le nettoyage enregistré par le backend. Sur XHR, ce nettoyage appelle xhr.abort() si la requête n'est pas terminée. Annuler un upload, c'est littéralement unsubscribe() :

import { Subscription } from 'rxjs';

export class AvatarUploader {
  private uploadSub: Subscription | null = null;

  startUpload(file: File) {
    this.uploadSub?.unsubscribe(); // un seul upload en cours à la fois
    this.uploadSub = this.avatarUpload.upload(file).subscribe(/* ... */);
  }

  cancel() {
    this.uploadSub?.unsubscribe(); // -> xhr.abort(), la requête s'arrête
    this.uploadSub = null;
    this.state.set({ status: 'idle' });
  }
}

Le revers de la médaille : l'annulation implicite. Branche l'upload derrière un switchMap (sur un flux de sélection de fichiers), et chaque nouveau fichier annule l'envoi précédent, en silence. Relis le code ci-dessus : son unsubscribe() en tête de startUpload() fait le même choix, juste écrit noir sur blanc. Pour un avatar, ce choix est le bon. Pour des pièces jointes, presque jamais : chaque annulation silencieuse est un fichier qui manque côté serveur, sans erreur nulle part. Là, il te faut concatMap (file d'attente), exhaustMap (ignore les nouvelles sélections pendant l'envoi), ou un bouton désactivé tant qu'un envoi est en cours.


La version complète qui tient la route

Tout assemblé : un composant autonome, OnPush, état en signal avec union discriminée (un type où la valeur du champ status détermine les autres champs présents), progression et annulation.

// avatar-uploader.ts
import {
  ChangeDetectionStrategy,
  Component,
  inject,
  signal,
} from '@angular/core';
import { HttpClient, HttpEventType } from '@angular/common/http';
import { Subscription } from 'rxjs';

type UploadState =
  | { status: 'idle' }
  | { status: 'uploading'; progress: number }
  | { status: 'done' }
  | { status: 'error'; message: string };

@Component({
  selector: 'app-avatar-uploader',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <input type="file" accept="image/*" (change)="onFileSelected($event)" />

    @switch (state().status) {
      @case ('uploading') {
        <progress max="100" [value]="progressValue()"></progress>
        <button type="button" (click)="cancel()">Annuler</button>
      }
      @case ('done') {
        <p>Avatar mis à jour.</p>
      }
      @case ('error') {
        <p role="alert">L'envoi a échoué. Réessaie.</p>
      }
    }
  `,
})
export class AvatarUploader {
  private readonly http = inject(HttpClient);
  private uploadSub: Subscription | null = null;

  protected readonly state = signal<UploadState>({ status: 'idle' });

  protected progressValue(): number {
    const s = this.state();
    return s.status === 'uploading' ? s.progress : 0;
  }

  protected onFileSelected(event: Event) {
    const file = (event.target as HTMLInputElement).files?.[0];
    if (!file) return;

    const body = new FormData();
    body.append('avatar', file); // pas de Content-Type manuel (piège 1)

    this.uploadSub?.unsubscribe();
    this.state.set({ status: 'uploading', progress: 0 });

    this.uploadSub = this.http
      .post('/api/avatar', body, {
        reportUploadProgress: true, // piège 3 : produire les événements
        observe: 'events',          // piège 3 : les recevoir
      })
      .subscribe({
        next: (e) => {
          if (e.type === HttpEventType.UploadProgress && e.total !== undefined) {
            this.state.set({
              status: 'uploading',
              progress: Math.round((100 * e.loaded) / e.total),
            });
          } else if (e.type === HttpEventType.Response) {
            this.state.set({ status: 'done' });
          }
        },
        error: () =>
          this.state.set({ status: 'error', message: 'Upload failed' }),
      });
  }

  protected cancel() {
    this.uploadSub?.unsubscribe(); // piège 4 : interruption réelle de la requête
    this.uploadSub = null;
    this.state.set({ status: 'idle' });
  }
}

La config qui va avec, sans laquelle le piège 2 te rattrape :

// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withXhr } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [provideHttpClient(withXhr())],
};

Et si ton app est en SSR : app.config.ts est fusionné dans la config serveur via mergeApplicationConfig, donc sans contre-mesure ton serveur part en XHR aussi, ce qu'Angular signale en dev (warning NG02801). Le remède tient en une ligne, parce que pour le backend, entre deux provideHttpClient, le dernier fourni gagne (les interceptors fournis au même injecteur, eux, s'additionnent) :

// app.config.server.ts
import { ApplicationConfig, mergeApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { appConfig } from './app.config';

const serverConfig: ApplicationConfig = {
  providers: [
    // ...tes providers serveur existants
    provideHttpClient(), // re-fournit le défaut fetch, qui écrase withXhr()
  ],
};

export const config = mergeApplicationConfig(appConfig, serverConfig);

Tu n'y perds rien : personne n'uploade de fichier pendant le rendu serveur.


Récap actionnable

Symptôme Cause Fix
400 ou 500, corps multipart illisible côté serveur Content-Type: multipart/form-data posé à la main, boundary absent Supprimer le header, laisser HttpClient et le navigateur gérer
HttpErrorResponse status 0, NG02824 dans error (v22) Progression d'upload demandée au FetchBackend provideHttpClient(withXhr()), et re-fournir fetch dans la config serveur SSR
Barre morte, zéro erreur reportProgress (déprécié en v22) sur fetch, en v17-21 avec withFetch() comme en v22 Passer à reportUploadProgress + backend XHR, ou assumer l'état indéterminé
Aucun événement, juste la réponse finale (XHR) reportUploadProgress sans observe: 'events' Les deux options ensemble (pour post()/get())
Barre bloquée ou valeur NaN total est optionnel (total?: number) Ne calculer le pourcentage que si event.total !== undefined
Upload impossible à annuler Subscription jamais conservée, aucun opérateur de coupure La garder : unsubscribe() interrompt la requête XHR
Fichiers qui disparaissent sans erreur switchMap qui annule l'envoi précédent concatMap ou exhaustMap (une Subscription unique qui se remplace annule aussi)

L'upload de fichiers n'est pas compliqué, il est contre-intuitif : le header qu'il ne faut pas écrire, le transport par défaut qui ne sait pas mesurer, l'option qui ne marche que par paire, et l'annulation gratuite que personne n'utilise. Une fois les quatre pièges en tête, l'upload redevient un POST comme un autre.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.