DEV Community

Cover image for Angular 22 Quietly Replaced XHR -Here's What Actually Changes For You (Part 5)
Rajat
Rajat

Posted on

Angular 22 Quietly Replaced XHR -Here's What Actually Changes For You (Part 5)

HttpClient now runs on Fetch by default. Here's what that means for your SSR app, your tests, and that one file-upload feature everyone forgets about.

Quick question: when was the last time you actually stopped and thought about what HttpClient does under the hood every time you call .get() or .post()?

Most of us never think about it. It's plumbing. It works, so we move on. But with Angular 22 (released June 3, 2026), that plumbing just got swapped out from under you, and depending on what your app does, you might not notice anything — or you might get a very confusing bug report about a progress bar that stopped moving.

Here's the short version: HttpClient used to run every request through XMLHttpRequest (XHR) unless you explicitly opted into withFetch(). As of Angular 22, that's flipped. Fetch is now the default backend, and XHR is the thing you opt into.

By the end of this article, you'll know:

  • What actually changed in HttpClient and why the Angular team made the switch
  • How to write the provider setup with the new default (and how to fall back to XHR when you need to)
  • The one feature that quietly breaks if you're not paying attention (upload progress)
  • How this connects to SSR, streaming, and service worker caching
  • How to write unit tests against the new default without your test suite silently lying to you

If you're following along with server-side rendering, incremental hydration, or you just upgraded and your app is behaving slightly differently, this one's for you.

If this is useful, consider following along here on Medium — I write about Angular internals like this pretty regularly, and it helps more than you'd think.

Before we dive into the examples, a quick note: the code snippets here are meant purely for understanding the concept. Some syntax shown may reflect patterns from earlier Angular versions as the ecosystem keeps evolving quickly. Always check the official Angular documentation for the current API and syntax before shipping anything to production.

What Actually Changed in Angular 22

Let's start with the boring-sounding but important part. Before Angular 22, provideHttpClient() used XHR by default, and you had to explicitly add withFetch() if you wanted requests to go through the native fetch() API instead. In Angular 22, that default flipped — provideHttpClient() now uses FetchBackend automatically, and withFetch() is no longer something you need to write (in fact, it's been deprecated since it now describes the default behavior rather than an opt-in).

Here's what the setup looked like before, and what it looks like now.

// app.config.ts — Angular 21 and earlier
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withFetch } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(withFetch()), // had to opt in explicitly
  ],
};
Enter fullscreen mode Exit fullscreen mode
// app.config.ts — Angular 22+
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(), // Fetch backend, automatically
  ],
};
Enter fullscreen mode Exit fullscreen mode

That's it. No new API to learn, no migration steps for the common case. If you never touched withFetch() or withXhr() before, your app is already running on the new backend the moment you upgrade.

Why the Angular Team Made the Switch

A few reasons keep coming up when you dig into why this matters:

Better SSR compatibility. fetch() is a standard API that exists natively in both the browser and in server runtimes like Node.js. XMLHttpRequest is a browser-only construct that had to be polyfilled or shimmed for server-side rendering. Standardizing on fetch() removes an entire category of "works in the browser, breaks on the server" bugs.

Streaming support. Fetch's response body is a readable stream by design, which lines up naturally with patterns like streaming SSR and incremental hydration that Angular has been leaning into over the last few releases.

Smaller bundles. Because the XHR code path is no longer needed by default, apps that don't explicitly ask for it can ship a slightly leaner bundle.

Service worker synergy. Since fetch() is the same API the Fetch event in a service worker listens for, an Angular app using @angular/service-worker can now have its HttpClient calls participate in caching strategies defined in ngsw-config.json without extra wiring.

Ever run into a weird SSR-only bug that never showed up locally? Tell me about it in the comments — I'm willing to bet a chunk of those were XHR-related timing issues in disguise.

The One Thing That Breaks: Upload Progress

Here's the gotcha, and it's a real one if your app has file uploads.

FetchBackend does not support upload progress events. If you have code that watches reportProgress: true and listens for HttpEventType.UploadProgress on a POST request, that progress reporting will silently stop working once you're on the Fetch backend — no error, no warning, the progress bar just never moves past 0 (or jumps straight to 100).

The fix is to explicitly request the XHR backend for the parts of your app that need it, using withXhr():

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

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(withXhr()), // opt back into XHR app-wide
  ],
};
Enter fullscreen mode Exit fullscreen mode

If you only need XHR for a specific feature rather than your whole app, you can inject a separate HttpClient scoped to a child injector that provides withXhr(), and use that client just for upload calls. That way the rest of your app keeps the benefits of Fetch, and only the upload path pays the XHR cost.

A realistic upload service, written with signals and the current dependency injection style, looks like this:

import { HttpClient, HttpEvent, HttpEventType } from '@angular/common/http';
import { inject, Injectable, signal } from '@angular/core';
import { map } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class FileUploadService {
  private http = inject(HttpClient);
  readonly uploadProgress = signal(0);

  uploadFile(file: File) {
    const formData = new FormData();
    formData.append('file', file);

    return this.http
      .post('/api/upload', formData, {
        reportProgress: true,
        observe: 'events',
      })
      .pipe(
        map((event: HttpEvent<unknown>) => {
          if (event.type === HttpEventType.UploadProgress && event.total) {
            this.uploadProgress.set(Math.round((100 * event.loaded) / event.total));
          }
          return event;
        }),
      );
  }
}
Enter fullscreen mode Exit fullscreen mode

Remember: this only reports progress correctly if the injector providing this service is configured with withXhr(). On the default Fetch backend, event.total for upload events simply won't fire the way you expect.

Putting It Together in a Component

Here's a small, complete component that consumes the service above, using standalone architecture, signal-based state, and the new control flow syntax:

import { Component, inject, signal } from '@angular/core';
import { FileUploadService } from './file-upload.service';

@Component({
  selector: 'app-file-upload',
  template: `
    <input type="file" (change)="onFileSelected($event)" />

    @if (uploading()) {
      <p>Uploading… {{ uploadService.uploadProgress() }}%</p>
    } @else {
      <p>Choose a file to upload.</p>
    }
  `,
})
export class FileUploadComponent {
  protected uploadService = inject(FileUploadService);
  protected uploading = signal(false);

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

    this.uploading.set(true);
    this.uploadService.uploadFile(file).subscribe({
      complete: () => this.uploading.set(false),
      error: () => this.uploading.set(false),
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice there's no NgModule, no *ngIf, and no @Input() decorator anywhere. This is standalone by default, using @if for conditional rendering, which is exactly how a fresh Angular 22 project scaffolds things today.

Writing Unit Tests Against the Fetch Backend

Testing HTTP calls hasn't fundamentally changed in shape — you still use the HttpClient testing utilities to intercept and assert against requests. What's worth double-checking is that your test provider setup matches whatever backend your app actually runs in production, especially if you've explicitly opted into withXhr() somewhere.

import { TestBed } from '@angular/core/testing';
import {
  HttpTestingController,
  provideHttpClientTesting,
} from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { FileUploadService } from './file-upload.service';

describe('FileUploadService', () => {
  let service: FileUploadService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        provideHttpClient(),
        provideHttpClientTesting(),
        FileUploadService,
      ],
    });

    service = TestBed.inject(FileUploadService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpMock.verify();
  });

  it('should POST the file to the upload endpoint', () => {
    const file = new File(['dummy content'], 'test.png', { type: 'image/png' });

    service.uploadFile(file).subscribe();

    const req = httpMock.expectOne('/api/upload');
    expect(req.request.method).toBe('POST');

    req.flush({});
  });

  it('should update uploadProgress on progress events', () => {
    const file = new File(['dummy content'], 'test.png', { type: 'image/png' });

    service.uploadFile(file).subscribe();

    const req = httpMock.expectOne('/api/upload');
    req.event({
      type: 1, // HttpEventType.UploadProgress
      loaded: 50,
      total: 100,
    } as any);

    expect(service.uploadProgress()).toBe(50);
  });
});
Enter fullscreen mode Exit fullscreen mode

A couple of things worth calling out: HttpTestingController intercepts requests regardless of which real backend (Fetch or XHR) is configured, since it replaces the backend entirely during testing. That's convenient, but it also means your unit tests won't catch the "upload progress silently stops working" issue described earlier — that only shows up with a real network layer, so it's worth covering with at least one integration or e2e test if uploads matter to your app.

Have you hit a case where your test suite passed but the real backend behaved differently once deployed? That's exactly the kind of gap worth sharing — drop a comment with what tripped you up.

Migration Notes

If you're upgrading an existing app to Angular 22, running ng update @angular/core @angular/cli handles the mechanical parts. A few things worth checking manually afterward:

  • Search your codebase for withFetch() — it still works but is deprecated, so it's a good time to remove it since it's now redundant.
  • Search for reportProgress: true and confirm those call sites either don't rely on upload progress, or have been updated to use withXhr().
  • If you're running Angular on the server for SSR, double-check any custom interceptors that assumed XHR-specific behavior (things like inspecting XMLHttpRequestspecific properties directly rather than going through the HttpEvent API).
  • Angular 22 also requires TypeScript 6 and drops support for Node 20, so factor that into your upgrade path if you haven't already touched your toolchain.

Bonus Tips

A few smaller things that are easy to miss:

  • If your app talks to an API that returns very large responses, Fetch's native streaming support means you can process a response as it arrives rather than waiting for the whole payload, which is worth exploring if you're dealing with large downloads.
  • withXhr() isn't just an escape hatch for uploads — some corporate proxy setups and older embedded browser environments still behave more predictably with XHR, so keep it in your back pocket for edge-case debugging.
  • If you're using @angular/service-worker, revisit your ngsw-config.json caching strategy now that HttpClient requests go through fetch() — you may be able to simplify caching logic that previously had to work around XHR quirks.
  • Pair this change with Angular's httpResource() API if you haven't already looked at it — it builds on HttpClient and gives you a signal-based way to load data without manually wiring up subscriptions.

Recap

Angular 22 flips HttpClient's default backend from XHR to Fetch. For most apps, this is a zero-line-change upgrade — you get better SSR compatibility, native streaming support, and service worker synergy for free. The one place to slow down is upload progress tracking, since FetchBackend doesn't support it; if your app needs it, reach for withXhr() explicitly. Unit tests using HttpTestingController continue to work the same way regardless of backend, but they won't catch backend-specific behavior gaps on their own, so keep that in mind for anything progress-related.

What's your take: are you planning to stick with the new Fetch default, or do you have a use case that pushes you back to XHR? Let me know in the comments.


What did you think?
Did this approach match how you are solving it, or do you have a different take? Drop a comment — I genuinely read every single one.

Found this helpful?
If this saved you even a few minutes of debugging or confusion, hit that clap button so others can find it too. It really does make a difference.

Want more tips like this?
I share one practical dev insight every week. Follow me here on Medium or subscribe to my newsletter so you never miss one.

Let us connect — find me on LinkedIn or GitHub and let us keep the conversation going.


🚀 Follow Me for More Angular & Frontend Goodness:

I regularly share hands-on tutorials, clean code tips, scalable frontend architecture, and real-world problem-solving guides.

  • 💼 LinkedIn — Let’s connect professionally
  • 🎥 Threads — Short-form frontend insights
  • 🐦 X (Twitter) — Developer banter + code snippets
  • 👥 BlueSky — Stay up to date on frontend trends
  • 🌟 GitHub Projects — Explore code in action
  • 🌐 Website — Everything in one place
  • 📚 Medium Blog — Long-form content and deep-dives
  • 💬 Dev Blog — Free Long-form content and deep-dives
  • ✉️ Substack — Weekly frontend stories & curated resources
  • 🧩 Portfolio — Projects, talks, and recognitions
  • ✍️ Hashnode — Developer blog posts & tech discussions
  • ✍️ Reddit** — Developer blog posts & tech discussions

Top comments (0)