HTTP streaming with Angular's built-in HttpClient

HTTP streaming with Angular's built-in HttpClient

Handle large data sets incrementally through "chunks" to improve both UX and performance

·

4 min read

Why?

Web streams are the go-to way of handling continuous async data transfer in the browser. By using them, we can process smaller "chunks" of data as we receive them, instead of waiting for a large data set to completely load. This enables us to create more responsive UIs, since rendering can begin as soon as we received our first chunk.


When?

Usually pages that needs to load and render a large amount of data can benefit the most from chunked responses. Think maps, charts, tables, graphs and/or other data heavy components, which at the same time are highly interactive.


How?

With the relatively new FetchBackend (added with this commit), we're able to use web streams natively through the Fetch API.

The first step is to enable it in our ApplicationConfig (used as the second parameter in the bootstrapApplication call in our main.ts).

bootstrapApplication(AppComponent, {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    // Enable FetchBackend by calling withFetch()
    provideHttpClient(withFetch())
  ]
}).catch((err) => console.error(err));

Now that we have it enabled, we will need to set some options on our calls:

  • observe: 'events' : Needed to see all events, mainly the progress of our transfers

  • responseType: 'text' : Needed access the partialText field of the event, which will contain our concatenated chunks

  • reportProgress: true : Needed to ensure that the Observable will emit on each partial chunk update

this._http.get(environment.BACKEND_URL, {
    observe: 'events',
    responseType: 'text',
    reportProgress: true
}).subscribe(() => ...)

We'll have two main events that we'll want to focus on, each representing one of the possible states of the request:

  • HttpEventType.DownloadProgress : The event will contain a partialText field, which contains the chunks received this far, plus the newest chunk concatenated to it

  • HttpEventType.Response : The event's body will contain the entire data set, since the transfer has been finished

this._http.get(...).subscribe((event: HttpEvent<string>) => {
  if (event.type === HttpEventType.DownloadProgress) {
    this.response.content = (event as HttpDownloadProgressEvent).partialText!;
  } else if (event.type === HttpEventType.Response) {
    this.response.content = event.body!;
  }
});

The code above is basically everything that we'll need to be able to stream our data.

We can see the two cases handled separately - for the purpose of this simple demo, we want to treat them the same way: when a new version of the concatenated chunks arrives, we want to display it, and when all data is received, we want to display the entire response body.

The fully set-up component with some extra "bells and whistles" looks like this:

export class AppComponent {
  private readonly _http = inject(HttpClient);

  loadingResponse: boolean = false;
  response = { content: '' };
  responses = signal<{ id: number }[]>([]);

  getData() {
    this.loadingResponse = true;
    this._http.get(environment.BACKEND_URL, { observe: 'events', responseType: 'text', reportProgress: true }).subscribe((event: HttpEvent<string>) => {
      if (event.type === HttpEventType.DownloadProgress) {
        const partial = (event as HttpDownloadProgressEvent).partialText!;
        this.response.content = partial;

        // This is used for the grid values
        const responseObject = this.convertToObjectArray(partial!);
        this.responses.set(responseObject);
      } else if (event.type === HttpEventType.Response) {
        this.response.content = event.body!;
        this.loadingResponse = false;
      }
    });
  }

  private convertToObjectArray(responseContent: string) {
    if (responseContent.slice(-1) !== ']') {
      responseContent += ']';
    }

    return JSON.parse(responseContent);
  }
}

How does this look like in practice? Let's take a look.

If we take a closer look to our developer console, we can see that all of the data seen above is being streamed chunk-by-chunk via a single outgoing request:

And with that, we're done!


Closing thoughts

Sadly, Angular's current behavior is to concatenate the previously received chunks and the latest one. There's an open issue in the official Angular repo on Github regarding this topic - at the time of writing, if you want to process each chunk individually, sadly you'll have to resort to the Fetch API (Gist with the drop-in replacement method can be grabbed here).

This article was more focused on the TypeScript part of the implementation - the template here is just a simple way of displaying the streamed data, and has no implementation-specific details, so it was omitted.

As usual, all the code used is available here for the Angular app, and here for the .NET 8 backend used for the demo.