Required reading before reading this blog post:
You're using HttpClient wrong and it is destabilizing your software

TL;DR

  • Don't use .Result.
  • Stream the response instead of storing the whole response in a string.
  • If you really care about performance, create a custom json deserializer.
  • Reuse HttpClient (or use IHttpClientFactory).

Introduction

All code in this blog post can be found here together with instructions on how to run the benchmarks. This post focuses on dotnet core, the benchmarks are run on dotnet core 3 preview3.

HttpClient is really easy to use, and because of that, it's also really easy to use it wrong. Since it's so easy to use, nobody takes the time to really learn how to use it correctly, my code works, why should I change it? My goal with this post is to show how to use HttpClient in the most efficient way.
What do I mean by the most efficient way?

  • Try to run as fast as possible
  • Try to allocate as little memory as possible

Steve Gordon has a bunch of posts regarding HttpClient, I really recommend that you check out his blog if you want to learn more.

The problem

We want to fetch JSON data from an external API and return a subset of it from a controller.
Our architecture will look like this:
Controller -> IGetAllProjectsQuery -> IGitHubClient.
The Query is responsible for mapping the data from DTOs to "domain models".
Note: We will NOT use the real GitHub API, I've created a API that returns dummy data, I just choose to name it GitHub to make the code more...authentic.

The size of the json response will differ between the benchmarks, we will run the benchmarks 4 times with the following sizes:

  • 10 items ~ 11 KB
  • 100 items ~ 112 KB
  • 1 000 items ~ 1 116 KB
  • 10 000 items ~ 11 134 KB

Error handling

I've intentionally left out all error handling here because I wanted to focus on the code that fetches/deserializes data. I have another post coming up that's full with opinions of how to use HttpClient through out your code base with many different third party integrations together with error handling, Polly and so on, so stay tuned!

Without IHttpClientFactory

This is just to show how one could do this before the IHttpClientFactory existed.
If you don't have access to IHttpClientFactory for whatever reason, look at Version 2 and store the HttpClient as a private field so that it could be reused. Also, don't forget to read the Optimization section!

Version 0

Ah, only a few lines of code, what could POSSIBLY be wrong with this code?
This code creates a new HttpClient in a using statement, calls .Result on GetStringAsync and saves the whole response in a string. It was hard writing this code because it goes against everything I stand for :).

Version0Configurator.cs

public static class Version0Configurator
{
    public static void AddVersion0(this IServiceCollection services)
    {
        services.AddSingleton<GetAllProjectsQuery>();
        services.AddSingleton<GitHubClient>();
    }
}

GitHubClient.cs

public class GitHubClient
{
    public IReadOnlyCollection<GitHubRepositoryDto> GetRepositories()
    {
        using (var httpClient = new HttpClient{BaseAddress = new Uri(GitHubConstants.ApiBaseUrl)})
        {
            var result = httpClient.GetStringAsync(GitHubConstants.RepositoriesPath).Result;
            return JsonConvert.DeserializeObject<List<GitHubRepositoryDto>>(result);
        }
    }
}

Pros

NONE

Cons

  • Using .Result on an asynchronous method. It's never a good idea, never.
    But what if I use .GetAwaiter().GetResult()???
    Nope, still bad. You can read more about common async gotchas/pitfalls/recommendations here. It's written by David Fowler (member of the ASP.NET team), he knows what he's talking about.
  • Creating a new HttpClient for every call in a using statement. HttpClient should not be disposed (well, it should, but not by you, more on that further down where I talk about IHttpClientFactory.
  • Fetching the whole response and storing it as a string, this is obviously bad when working with large response objects, they will end up on the Large object heap if they are larger than 85 000 bytes.

Version 1

We have now wrapped the return type in a Task<> and also added the async keyword. This allows us to await the call to .GetStringAsync.

Version1Configurator.cs

public static class Version1Configurator
{
    public static void AddVersion1(this IServiceCollection services)
    {
        services.AddSingleton<GetAllProjectsQuery>();
        services.AddSingleton<GitHubClient>();
    }
}

GitHubClient.cs

public class GitHubClient : IGitHubClient
{
    public async Task<IReadOnlyCollection<GitHubRepositoryDto>> GetRepositories()
    {
        using (var httpClient = new HttpClient { BaseAddress = new Uri(GitHubConstants.ApiBaseUrl) })
        {
            var result = await httpClient.GetStringAsync(GitHubConstants.RepositoriesPath).ConfigureAwait(false);
            return JsonConvert.DeserializeObject<List<GitHubRepositoryDto>>(result);
        }
    }
}

Pros

  • The request to the API is asynchronous.

Cons

  • Creating a new HttpClient for every call in a using statement.
  • Fetching and storing the whole response in a string.

Version 2

We are now creating a HttpClient in the constructor and then storing it as a field so that we can reuse it. Note, all implementations of the GitHubClients so far are intended to be used/registered as singeltons

Version2Configurator.cs

public static class Version2Configurator
{
    public static void AddVersion2(this IServiceCollection services)
    {
        services.AddSingleton<GetAllProjectsQuery>();
        services.AddSingleton<GitHubClient>();
    }
}

GitHubClient.cs

public class GitHubClient : IGitHubClient
{
    private readonly HttpClient _httpClient;

    public GitHubClient()
    {
        _httpClient = new HttpClient { BaseAddress = new Uri(GitHubConstants.ApiBaseUrl) };
    }

    public async Task<IReadOnlyCollection<GitHubRepositoryDto>> GetRepositories()
    {
        var result = await _httpClient.GetStringAsync(GitHubConstants.RepositoriesPath).ConfigureAwait(false);
        return JsonConvert.DeserializeObject<List<GitHubRepositoryDto>>(result);
    }
}

Pros

  • The request to the API is asynchronous.
  • Reusing the HttpClient

Cons

  • Fetching and storing the whole response in a string.
  • Since we are resuing the HttpClient forever, DNS changes won't be respected. You can read more about that here.

With IHttpClientFactory

As you have seen so far, it's really easy to use HttpClient wrong, here's what Microsoft has to say about it.

The original and well-known HttpClient class can be easily used, but in some cases, it isn't being properly used by many developers.
As a first issue, while this class is disposable, using it with the using statement is not the best choice because even when you dispose HttpClient object, the underlying socket is not immediately released and can cause a serious issue named ‘sockets exhaustion’.

Therefore, HttpClient is intended to be instantiated once and reused throughout the life of an application. Instantiating an HttpClient class for every request will exhaust the number of sockets available under heavy loads. That issue will result in SocketException errors. Possible approaches to solve that problem are based on the creation of the HttpClient object as singleton or static.

But there’s a second issue with HttpClient that you can have when you use it as singleton or static object. In this case, a singleton or static HttpClient doesn't respect DNS changes.
To address those mentioned issues and make the management of HttpClient instances easier, .NET Core 2.1 introduced a new HttpClientFactory...

Version 3

Here, we are injecting the IHttpClientFactory and then using it to create a new HttpClient every time the method gets called.
Yes, we are creating a new HttpClient every time, that's not a bad thing anymore since we are using the IHttpClientFactory.
Straight from Microsoft:

Each time you get an HttpClient object from the IHttpClientFactory, a new instance is returned. But each HttpClient uses an HttpMessageHandler that's pooled and reused by the IHttpClientFactory to reduce resource consumption, as long as the HttpMessageHandler's lifetime hasn't expired.
Pooling of handlers is desirable as each handler typically manages its own underlying HTTP connections; creating more handlers than necessary can result in connection delays. Some handlers also keep connections open indefinitely, which can prevent the handler from reacting to DNS changes.

Version3Configurator.cs

public static class Version3Configurator
{
    public static void AddVersion3(this IServiceCollection services)
    {
        services.AddSingleton<GetAllProjectsQuery>();
        services.AddHttpClient("GitHub", x => { x.BaseAddress = new Uri(GitHubConstants.ApiBaseUrl); });
        services.AddSingleton<GitHubClient>();
    }
}

GitHubClient.cs

public class GitHubClient : IGitHubClient
{
    private readonly IHttpClientFactory _httpClientFactory;

    public GitHubClient(IHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
    }

    public async Task<IReadOnlyCollection<GitHubRepositoryDto>> GetRepositories()
    {
        var httpClient = _httpClientFactory.CreateClient("GitHub");
        var result = await httpClient.GetStringAsync(GitHubConstants.RepositoriesPath).ConfigureAwait(false);
        return JsonConvert.DeserializeObject<List<GitHubRepositoryDto>>(result);
    }
}

Pros

  • Using IHttpClientFactory

Cons

Version 4

Here we are using a typed client instead of a named one. We are registering the typed client with the .AddHttpClient<> method. Note that we also changed the registration of GetAllProjectsQuery from singleton to transient since typed clients are registered as transient.

Version4Configurator.cs

public static class Version4Configurator
{
    public static void AddVersion4(this IServiceCollection services)
    {
        services.AddTransient<GetAllProjectsQuery>();
        services.AddHttpClient<GitHubClient>(x => { x.BaseAddress = new Uri(GitHubConstants.ApiBaseUrl); });
    }
}

GitHubClient.cs

public class GitHubClient : IGitHubClient
{
    private readonly HttpClient _httpClient;

    public GitHubClient(HttpClient httpClient)
    {
        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
    }

    public async Task<IReadOnlyCollection<GitHubRepositoryDto>> GetRepositories()
    {
        var result = await _httpClient.GetStringAsync(GitHubConstants.RepositoriesPath).ConfigureAwait(false);
        return JsonConvert.DeserializeObject<List<GitHubRepositoryDto>>(result);
    }
}

Pros

  • Using IHttpClientFactory
  • Using a typed client

Cons

  • Needed to change the lifetime of GetAllProjectsQuery from singleton to transient.
  • Fetching and storing the whole response in a string.

Version 5

I really want my GetAllProjectsQuery to be a singleton. To be able to solve this I've created a GitHubClientFactory that returns a GitHubClient. The trick here is that it resolves the GitHubClient from the ServiceProvider, thus injecting all dependencies that we need, no need to new it up ourselfes. Who taught me that? Steve of course.

Version5Configurator.cs

public static class Version5Configurator
{
    public static void AddVersion5(this IServiceCollection services)
    {
        services.AddSingleton<GetAllProjectsQuery>();
        services.AddHttpClient<GitHubClient>(x => { x.BaseAddress = new Uri(GitHubConstants.ApiBaseUrl); });
        services.AddSingleton<GitHubClientFactory>();
    }
}

GitHubClientFactory.cs

public class GitHubClientFactory
{
    private readonly IServiceProvider _serviceProvider;

    public GitHubClientFactory(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public GitHubClient Create()
    {
        return _serviceProvider.GetRequiredService<GitHubClient>();
    }
}
public class GetAllProjectsQuery : IGetAllProjectsQuery
{
    private readonly GitHubClientFactory _gitHubClientFactory;

    public GetAllProjectsQuery(GitHubClientFactory gitHubClientFactory)
    {
        _gitHubClientFactory = gitHubClientFactory ?? throw new ArgumentNullException(nameof(gitHubClientFactory));
    }

    public async Task<IReadOnlyCollection<Project>> Execute()
    {
        var gitHubClient = _gitHubClientFactory.Create();
        var response = await gitHubClient.GetRepositories().ConfigureAwait(false);
        return response.Select(x => new Project(x.Name, x.Url, x.Stars)).ToArray();
    }
}

GitHubClient.cs
Looks the same as Version 4.

Pros

  • Using IHttpClientFactory
  • Using a typed client
  • GetAllProjectsQuery is now a singleton again

Cons

  • Fetching and storing the whole response in a string.

Optimization

So, with version 5 we are using a typed client and GetAllProjectsQuery is registered as a singleton, nice, only thing left is to try to get the code perform as good as possible, I have a few tricks :).

Version 6

We are now using the .SendAsync method instead of GetStringAsync. This is to allow us to stream the response instead of fetching it as a string. I've added the HttpCompletionOption.ResponseContentRead parameter to the code for brevity, it's the default option.
Now we are streaming the response from the HttpClient straight into the Deserialize method. We are also injecting the JsonSerializer that we have registered as a singleton.

Version6Configurator.cs

public static class Version6Configurator
{
    public static void AddVersion6(this IServiceCollection services)
    {
        services.AddSingleton<GetAllProjectsQuery>();
        services.AddHttpClient<GitHubClient>(x => { x.BaseAddress = new Uri(GitHubConstants.ApiBaseUrl); });
        services.AddSingleton<GitHubClientFactory>();
        services.AddSingleton<JsonSerializer>();
    }
}

GitHubClient.cs

public class GitHubClient : IGitHubClient
{
    private readonly HttpClient _httpClient;
    private readonly JsonSerializer _jsonSerializer;

    public GitHubClient(HttpClient httpClient, JsonSerializer jsonSerializer)
    {
        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
        _jsonSerializer = jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer));
    }

    public async Task<IReadOnlyCollection<GitHubRepositoryDto>> GetRepositories()
    {
        var request = CreateRequest();
        var result = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead).ConfigureAwait(false);

        using (var responseStream = await result.Content.ReadAsStreamAsync())
        {
            using (var streamReader = new StreamReader(responseStream))
            using (var jsonTextReader = new JsonTextReader(streamReader))
            {
                return _jsonSerializer.Deserialize<List<GitHubRepositoryDto>>(jsonTextReader);
            }
        }
    }

    private static HttpRequestMessage CreateRequest()
    {
        return new HttpRequestMessage(HttpMethod.Get, GitHubConstants.RepositoriesPath);
    }
}

Pros

  • Using IHttpClientFactory
  • Using a typed client
  • Streaming the response

Cons

  • ResponseContentRead

Version 7

The only difference here is that we are using ResponseHeadersRead instead of ResponseContentRead. You can read more about the difference between ResponseContentRead vs ResponseHeadersRead here but it basically boils down to that methods using ResponseContentRead waits until both the headers AND content is read where as methods using ResponseHeadersRead just reads the headers and then returns.

public class GitHubClient : IGitHubClient
{
    private readonly HttpClient _httpClient;
    private readonly JsonSerializer _jsonSerializer;

    public GitHubClient(HttpClient httpClient, JsonSerializer jsonSerializer)
    {
        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
        _jsonSerializer = jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer));
    }

    public async Task<IReadOnlyCollection<GitHubRepositoryDto>> GetRepositories()
    {
        var request = CreateRequest();
        var result = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);

        using (var responseStream = await result.Content.ReadAsStreamAsync())
        {
            using (var streamReader = new StreamReader(responseStream))
            using (var jsonTextReader = new JsonTextReader(streamReader))
            {
                return _jsonSerializer.Deserialize<List<GitHubRepositoryDto>>(jsonTextReader);
            }
        }
    }

    private static HttpRequestMessage CreateRequest()
    {
        return new HttpRequestMessage(HttpMethod.Get, GitHubConstants.RepositoriesPath);
    }
}

Pros

  • Using IHttpClientFactory
  • Using a typed client
  • Streaming the response
  • Using ResponseHeadersRead

Cons

  • Maybe the json deserialization could be improved?

Version 8

Here we've created a custom Json deserializer for JSON.Net, you can find a bunch of performance tips regarding JSON.Net here.

Version8Configurator.cs

public static class Version8Configurator
{
    public static void AddVersion8(this IServiceCollection services)
    {
        services.AddSingleton<GetAllProjectsQuery>();
        services.AddHttpClient<GitHubClient>(x => { x.BaseAddress = new Uri(GitHubConstants.ApiBaseUrl); });
        services.AddSingleton<GitHubClientFactory>();
        services.AddSingleton<JsonSerializer>();
        services.AddSingleton<ProjectDeserializer>();
    }
}

ProjectDeserializer.cs

public class ProjectDeserializer
{
    public IReadOnlyCollection<GitHubRepositoryDto> Deserialize(JsonTextReader jsonTextReader)
    {
        var repositories = new List<GitHubRepositoryDto>();
        var currentPropertyName = string.Empty;
        GitHubRepositoryDto repository = null;
        while (jsonTextReader.Read())
        {
            switch (jsonTextReader.TokenType)
            {
                case JsonToken.StartObject:
                    repository = new GitHubRepositoryDto();
                    continue;
                case JsonToken.EndObject:
                    repositories.Add(repository);
                    continue;
                case JsonToken.PropertyName:
                    currentPropertyName = jsonTextReader.Value.ToString();
                    continue;
                case JsonToken.String:
                    switch (currentPropertyName)
                    {
                        case "name":
                            repository.Name = jsonTextReader.Value.ToString();
                            continue;
                        case "url":
                            repository.Url = jsonTextReader.Value.ToString();
                            continue;
                    }
                    continue;
                case JsonToken.Integer:
                    switch (currentPropertyName)
                    {
                        case "stars":
                            repository.Stars = int.Parse(jsonTextReader.Value.ToString());
                            continue;
                    }
                    continue;
            }
        }
        return repositories;
    }
}

GitHubClient.cs

public class GitHubClient : IGitHubClient
{
    private readonly HttpClient _httpClient;
    private readonly ProjectDeserializer _projectDeserializer;

    public GitHubClient(HttpClient httpClient, ProjectDeserializer projectDeserializer)
    {
        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
        _projectDeserializer = projectDeserializer ?? throw new ArgumentNullException(nameof(projectDeserializer));
    }

    public async Task<IReadOnlyCollection<GitHubRepositoryDto>> GetRepositories()
    {
        var request = CreateRequest();
        var result = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);

        using (var streamReader = new StreamReader(await result.Content.ReadAsStreamAsync()))
        using (var jsonTextReader = new JsonTextReader(streamReader))
        {
            return _projectDeserializer.Deserialize(jsonTextReader);
        }
    }

    private static HttpRequestMessage CreateRequest()
    {
        return new HttpRequestMessage(HttpMethod.Get, GitHubConstants.RepositoriesPath);
    }
}

Pros

  • Using IHttpClientFactory
  • Using a typed client
  • Streaming the response
  • Using ResponseHeadersRead
  • Optimized the json deserialization

Cons

  • As pointed out in the comments, we are still blocking when we are deserializing the response.

Version 9

This version uses the new json serializer from Microsoft.

Version9Configurator.cs

public static class Version9Configurator
{
    public static void AddVersion9(this IServiceCollection services)
    {
        services.AddSingleton<GetAllProjectsQuery>();
        services.AddHttpClient<GitHubClient>("GitHubClient.Version9", x => { x.BaseAddress = new Uri(GitHubConstants.ApiBaseUrl); });
        services.AddSingleton<GitHubClientFactory>();
    }
}

GitHubClient.cs

public class GitHubClient : IGitHubClient
{
    private readonly HttpClient _httpClient;

    public GitHubClient(HttpClient httpClient)
    {
        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
    }

    public async Task<IReadOnlyCollection<GitHubRepositoryDto>> GetRepositories()
    {
        var request = CreateRequest();
        var result = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);

        using (var contentStream = await result.Content.ReadAsStreamAsync())
        {
            return await JsonSerializer.DeserializeAsync<List<GitHubRepositoryDto>>(contentStream, DefaultJsonSerializerOptions.Options);;
        }
    }

    private static HttpRequestMessage CreateRequest()
    {
        return new HttpRequestMessage(HttpMethod.Get, GitHubConstants.RepositoriesPath);
    }
}

Pros

  • Using IHttpClientFactory
  • Using a typed client
  • Streaming the response
  • Using ResponseHeadersRead
  • Using the new System.Text.Json serializer that allows async deserialization.

Cons

  • ?

Version 10

Your version! Do you think you can make an even better version than my best version? Feel free to send a PR on GitHub and I will happily add it to the benchmarks and to this post!

Benchmarks

Time for some data!

I will run the different benchmarks four times, I will change how many items the API returns between the benchmarks.

  • First run: 10 items ~ 11 KB
  • Second run: 100 items ~ 112 KB
  • Third run: 1 000 items ~ 1 116 KB
  • Fourth run: 10 000 items ~ 11 134 KB

Every pass in the benchmark run invokes the method 50 times.

Benchmark 1

This benchmark resolves the GetAllProjectsQuery from the ServiceProvider, fetches the data with the GitHubClient, deserializes it and then maps it to domain objects.

10 projects

BenchmarkDotNet=v0.12.0, OS=Windows 10.0.19025
Intel Core i7-8750H CPU 2.20GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores
.NET Core SDK=3.1.100-preview3-014645
  [Host]     : .NET Core 3.1.0 (CoreCLR 4.700.19.53102, CoreFX 4.700.19.55104), X64 RyuJIT
  Job-VJTEAD : .NET Core 3.1.0 (CoreCLR 4.700.19.53102, CoreFX 4.700.19.55104), X64 RyuJIT

Runtime=.NET Core 3.1  InvocationCount=50  UnrollFactor=1  

|   Method |       Mean |     Error |      StdDev |     Median | Ratio | RatioSD | Gen 0 | Gen 1 | Gen 2 | Allocated |
|--------- |-----------:|----------:|------------:|-----------:|------:|--------:|------:|------:|------:|----------:|
| Version0 | 1,000.1 us |  19.87 us |    43.62 us |   983.6 us |  1.00 |    0.00 |     - |     - |     - |  75.51 KB |
| Version1 | 3,127.8 us | 482.61 us | 1,423.00 us | 3,948.8 us |  2.53 |    1.64 |     - |     - |     - |  75.29 KB |
| Version2 |   397.0 us |   9.22 us |    26.75 us |   395.4 us |  0.41 |    0.03 |     - |     - |     - |   58.1 KB |
| Version3 |   389.1 us |   7.76 us |    20.03 us |   387.1 us |  0.39 |    0.03 |     - |     - |     - |  60.23 KB |
| Version4 |   407.0 us |  10.31 us |    29.92 us |   399.3 us |  0.41 |    0.03 |     - |     - |     - |  59.32 KB |
| Version5 |   421.8 us |   9.65 us |    27.99 us |   417.3 us |  0.43 |    0.03 |     - |     - |     - |   59.3 KB |
| Version6 |   412.4 us |  12.27 us |    35.59 us |   406.1 us |  0.43 |    0.04 |     - |     - |     - |  53.27 KB |
| Version7 |   394.1 us |   7.84 us |    21.33 us |   393.5 us |  0.40 |    0.03 |     - |     - |     - |  44.25 KB |
| Version8 |   367.4 us |   7.86 us |    22.43 us |   362.7 us |  0.37 |    0.03 |     - |     - |     - |  44.51 KB |
| Version9 | 1,124.6 us |  23.09 us |    39.83 us | 1,116.9 us |  1.11 |    0.06 |     - |     - |     - |  60.97 KB |

Things to note
Version 7 and 8 allocates the least amount of memory. Version9 that uses the new System.Text.Json serializer allocates more than Version 7 and 8 and performs quite bad in general, will this trend continue?

100 projects

BenchmarkDotNet=v0.12.0, OS=Windows 10.0.19025
Intel Core i7-8750H CPU 2.20GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores
.NET Core SDK=3.1.100-preview3-014645
  [Host]     : .NET Core 3.1.0 (CoreCLR 4.700.19.53102, CoreFX 4.700.19.55104), X64 RyuJIT
  Job-LOIBJZ : .NET Core 3.1.0 (CoreCLR 4.700.19.53102, CoreFX 4.700.19.55104), X64 RyuJIT

Runtime=.NET Core 3.1  InvocationCount=50  UnrollFactor=1  

|   Method |     Mean |     Error |    StdDev | Ratio | RatioSD |   Gen 0 |   Gen 1 |   Gen 2 | Allocated |
|--------- |---------:|----------:|----------:|------:|--------:|--------:|--------:|--------:|----------:|
| Version0 | 9.125 ms | 0.1798 ms | 0.2521 ms |  1.00 |    0.00 | 80.0000 | 40.0000 | 40.0000 | 499.21 KB |
| Version1 | 8.977 ms | 0.1731 ms | 0.1852 ms |  0.98 |    0.03 | 80.0000 | 40.0000 | 40.0000 | 499.58 KB |
| Version2 | 7.911 ms | 0.1979 ms | 0.3082 ms |  0.87 |    0.04 | 80.0000 | 40.0000 | 40.0000 | 482.74 KB |
| Version3 | 7.885 ms | 0.1266 ms | 0.1057 ms |  0.86 |    0.03 | 80.0000 | 40.0000 | 40.0000 | 483.85 KB |
| Version4 | 8.000 ms | 0.1556 ms | 0.1792 ms |  0.87 |    0.03 | 80.0000 | 40.0000 | 40.0000 | 483.96 KB |
| Version5 | 7.812 ms | 0.1544 ms | 0.1585 ms |  0.85 |    0.02 | 80.0000 | 40.0000 | 40.0000 | 483.96 KB |
| Version6 | 7.829 ms | 0.1505 ms | 0.1673 ms |  0.85 |    0.03 | 80.0000 | 20.0000 | 20.0000 | 396.49 KB |
| Version7 | 7.912 ms | 0.1574 ms | 0.1933 ms |  0.86 |    0.03 | 60.0000 |       - |       - | 306.16 KB |
| Version8 | 7.526 ms | 0.0877 ms | 0.0685 ms |  0.82 |    0.03 | 60.0000 |       - |       - | 309.23 KB |
| Version9 | 8.547 ms | 0.1663 ms | 0.1708 ms |  0.93 |    0.03 | 20.0000 |       - |       - | 106.67 KB |

Things to note
As soon as the size of the json response increased, Version 9 really starts to shine. Version 7 and 8 is not even close when it comes to allocation.

1 000 projects

BenchmarkDotNet=v0.12.0, OS=Windows 10.0.19025
Intel Core i7-8750H CPU 2.20GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores
.NET Core SDK=3.1.100-preview3-014645
  [Host]     : .NET Core 3.1.0 (CoreCLR 4.700.19.53102, CoreFX 4.700.19.55104), X64 RyuJIT
  Job-LGGORO : .NET Core 3.1.0 (CoreCLR 4.700.19.53102, CoreFX 4.700.19.55104), X64 RyuJIT

Runtime=.NET Core 3.1  InvocationCount=50  UnrollFactor=1  

|   Method |     Mean |    Error |   StdDev |   Median | Ratio | RatioSD |    Gen 0 |    Gen 1 |    Gen 2 |  Allocated |
|--------- |---------:|---------:|---------:|---------:|------:|--------:|---------:|---------:|---------:|-----------:|
| Version0 | 83.04 ms | 2.763 ms | 8.147 ms | 84.11 ms |  1.00 |    0.00 | 940.0000 | 660.0000 | 400.0000 | 4673.49 KB |
| Version1 | 82.84 ms | 3.010 ms | 8.876 ms | 83.35 ms |  1.00 |    0.13 | 940.0000 | 660.0000 | 400.0000 | 4673.26 KB |
| Version2 | 82.90 ms | 3.000 ms | 8.847 ms | 85.61 ms |  1.01 |    0.16 | 960.0000 | 660.0000 | 420.0000 | 4657.06 KB |
| Version3 | 82.76 ms | 2.990 ms | 8.816 ms | 85.76 ms |  1.01 |    0.15 | 900.0000 | 620.0000 | 380.0000 | 4657.83 KB |
| Version4 | 82.65 ms | 2.870 ms | 8.463 ms | 85.89 ms |  1.01 |    0.16 | 900.0000 | 600.0000 | 380.0000 | 4657.94 KB |
| Version5 | 82.86 ms | 3.107 ms | 9.161 ms | 85.37 ms |  1.01 |    0.16 | 940.0000 | 660.0000 | 400.0000 | 4658.11 KB |
| Version6 | 82.76 ms | 3.146 ms | 9.276 ms | 85.78 ms |  1.01 |    0.16 | 740.0000 | 460.0000 | 220.0000 |  3757.4 KB |
| Version7 | 82.95 ms | 3.090 ms | 9.112 ms | 85.88 ms |  1.01 |    0.15 | 540.0000 | 240.0000 |        - | 2852.27 KB |
| Version8 | 83.06 ms | 2.971 ms | 8.761 ms | 86.83 ms |  1.01 |    0.12 | 560.0000 | 220.0000 |        - | 2883.18 KB |
| Version9 | 82.76 ms | 3.068 ms | 9.047 ms | 86.88 ms |  1.00 |    0.13 | 100.0000 |  40.0000 |        - |  564.68 KB |

Things to note
Version 9 continues to keep the allocations really low compared to the other verisons.
Except for Version 9, it's only version 7 and 8 that does not cause any gen 2 collects. Version 6 that uses ResponseContentRead causes gen 2 collects but version 7 that uses ResponseHeadersRead does not...

10 000 projects

BenchmarkDotNet=v0.12.0, OS=Windows 10.0.19025
Intel Core i7-8750H CPU 2.20GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores
.NET Core SDK=3.1.100-preview3-014645
  [Host]     : .NET Core 3.1.0 (CoreCLR 4.700.19.53102, CoreFX 4.700.19.55104), X64 RyuJIT
  Job-WPDQER : .NET Core 3.1.0 (CoreCLR 4.700.19.53102, CoreFX 4.700.19.55104), X64 RyuJIT

Runtime=.NET Core 3.1  InvocationCount=50  UnrollFactor=1  

|   Method |       Mean |    Error |   StdDev | Ratio | RatioSD |     Gen 0 |     Gen 1 |     Gen 2 | Allocated |
|--------- |-----------:|---------:|---------:|------:|--------:|----------:|----------:|----------:|----------:|
| Version0 |   805.2 ms | 15.08 ms | 14.10 ms |  1.00 |    0.00 | 5740.0000 | 1840.0000 |  980.0000 |  53.98 MB |
| Version1 |   804.1 ms | 16.00 ms | 17.12 ms |  1.00 |    0.02 | 5720.0000 | 1780.0000 |  980.0000 |  53.98 MB |
| Version2 |   811.2 ms | 15.96 ms | 17.08 ms |  1.01 |    0.02 | 5740.0000 | 1880.0000 |  980.0000 |  53.96 MB |
| Version3 |   814.2 ms | 15.79 ms | 14.77 ms |  1.01 |    0.03 | 5680.0000 | 1740.0000 |  980.0000 |  53.96 MB |
| Version4 |   806.5 ms | 13.47 ms | 12.60 ms |  1.00 |    0.03 | 5780.0000 | 1960.0000 |  980.0000 |  53.96 MB |
| Version5 |   803.7 ms | 16.06 ms | 20.88 ms |  1.00 |    0.03 | 5740.0000 | 1880.0000 |  980.0000 |  53.96 MB |
| Version6 | 1,094.9 ms | 28.47 ms | 82.59 ms |  1.20 |    0.19 | 5580.0000 | 1520.0000 | 1000.0000 |  36.36 MB |
| Version7 | 1,108.4 ms | 21.23 ms | 23.60 ms |  1.38 |    0.04 | 4980.0000 | 1300.0000 |  640.0000 |  27.55 MB |
| Version8 | 1,090.0 ms | 21.67 ms | 20.27 ms |  1.35 |    0.04 | 5120.0000 | 1340.0000 |  640.0000 |  27.85 MB |
| Version9 | 1,070.7 ms | 20.35 ms | 19.03 ms |  1.33 |    0.03 | 1000.0000 |  380.0000 |  180.0000 |   5.12 MB |

Things to note
Version 9, do I need to say more?

Benchmark 2

This benchmark resolves the GitHubClient from the ServiceProvider, fetches the data and deserializes it.

10 projects

BenchmarkDotNet=v0.12.0, OS=Windows 10.0.19025
Intel Core i7-8750H CPU 2.20GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores
.NET Core SDK=3.1.100-preview3-014645
  [Host]     : .NET Core 3.1.0 (CoreCLR 4.700.19.53102, CoreFX 4.700.19.55104), X64 RyuJIT
  Job-VJTEAD : .NET Core 3.1.0 (CoreCLR 4.700.19.53102, CoreFX 4.700.19.55104), X64 RyuJIT

Runtime=.NET Core 3.1  InvocationCount=50  UnrollFactor=1  

|   Method |       Mean |    Error |   StdDev | Ratio | RatioSD | Gen 0 | Gen 1 | Gen 2 | Allocated |
|--------- |-----------:|---------:|---------:|------:|--------:|------:|------:|------:|----------:|
| Version0 | 1,057.4 us | 31.31 us | 91.35 us |  1.00 |    0.00 |     - |     - |     - |  74.01 KB |
| Version1 | 1,014.1 us | 20.26 us | 49.32 us |  0.94 |    0.09 |     - |     - |     - |  75.29 KB |
| Version2 |   392.1 us |  7.82 us | 14.11 us |  0.35 |    0.02 |     - |     - |     - |  58.78 KB |
| Version3 |   396.3 us |  7.92 us | 18.66 us |  0.36 |    0.03 |     - |     - |     - |  59.84 KB |
| Version4 |   416.3 us |  8.62 us | 23.46 us |  0.39 |    0.04 |     - |     - |     - |  59.73 KB |
| Version5 |   402.1 us |  8.02 us | 21.54 us |  0.38 |    0.03 |     - |     - |     - |  59.77 KB |
| Version6 |   406.5 us |  8.30 us | 20.82 us |  0.38 |    0.03 |     - |     - |     - |  53.61 KB |
| Version7 |   402.0 us |  8.00 us | 19.16 us |  0.37 |    0.03 |     - |     - |     - |  44.52 KB |
| Version8 |   361.6 us |  7.43 us | 17.36 us |  0.33 |    0.03 |     - |     - |     - |  44.82 KB |
| Version9 | 1,157.0 us | 21.72 us | 20.31 us |  1.00 |    0.05 |     - |     - |     - |  61.89 KB |

Things to note
Same pattern as in benchmark 1, version 7 and 8 allocates the least amount while version 9 is lagging somewhat behind.

100 projects

BenchmarkDotNet=v0.12.0, OS=Windows 10.0.19025
Intel Core i7-8750H CPU 2.20GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores
.NET Core SDK=3.1.100-preview3-014645
  [Host]     : .NET Core 3.1.0 (CoreCLR 4.700.19.53102, CoreFX 4.700.19.55104), X64 RyuJIT
  Job-LOIBJZ : .NET Core 3.1.0 (CoreCLR 4.700.19.53102, CoreFX 4.700.19.55104), X64 RyuJIT

Runtime=.NET Core 3.1  InvocationCount=50  UnrollFactor=1  

|   Method |     Mean |     Error |    StdDev | Ratio | RatioSD |   Gen 0 |   Gen 1 |   Gen 2 | Allocated |
|--------- |---------:|----------:|----------:|------:|--------:|--------:|--------:|--------:|----------:|
| Version0 | 9.032 ms | 0.1826 ms | 0.1954 ms |  1.00 |    0.00 | 80.0000 | 40.0000 | 40.0000 | 494.61 KB |
| Version1 | 9.012 ms | 0.1387 ms | 0.1297 ms |  1.00 |    0.03 | 80.0000 | 40.0000 | 40.0000 | 494.67 KB |
| Version2 | 7.833 ms | 0.1551 ms | 0.1375 ms |  0.87 |    0.03 | 80.0000 | 40.0000 | 40.0000 | 477.84 KB |
| Version3 | 7.946 ms | 0.1581 ms | 0.1402 ms |  0.88 |    0.03 | 80.0000 | 40.0000 | 40.0000 | 478.95 KB |
| Version4 | 8.035 ms | 0.1491 ms | 0.2041 ms |  0.89 |    0.03 | 80.0000 | 40.0000 | 40.0000 | 479.03 KB |
| Version5 | 7.855 ms | 0.1564 ms | 0.1463 ms |  0.87 |    0.03 | 80.0000 | 40.0000 | 40.0000 | 479.03 KB |
| Version6 | 7.809 ms | 0.1505 ms | 0.1478 ms |  0.87 |    0.03 | 60.0000 | 20.0000 | 20.0000 |  391.6 KB |
| Version7 | 7.854 ms | 0.2158 ms | 0.2120 ms |  0.87 |    0.03 | 60.0000 |       - |       - | 301.25 KB |
| Version8 | 7.649 ms | 0.1515 ms | 0.2022 ms |  0.84 |    0.03 | 60.0000 |       - |       - | 304.33 KB |
| Version9 | 8.562 ms | 0.1706 ms | 0.2335 ms |  0.95 |    0.04 | 20.0000 |       - |       - | 101.75 KB |

Things to note
Same pattern as in benchmark 1, Version 9 starts to shine as soon as the repsonse body size goes up.

1 000 projects

BenchmarkDotNet=v0.12.0, OS=Windows 10.0.19025
Intel Core i7-8750H CPU 2.20GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores
.NET Core SDK=3.1.100-preview3-014645
  [Host]     : .NET Core 3.1.0 (CoreCLR 4.700.19.53102, CoreFX 4.700.19.55104), X64 RyuJIT
  Job-LGGORO : .NET Core 3.1.0 (CoreCLR 4.700.19.53102, CoreFX 4.700.19.55104), X64 RyuJIT

Runtime=.NET Core 3.1  InvocationCount=50  UnrollFactor=1  

|   Method |     Mean |    Error |   StdDev |   Median | Ratio | RatioSD |    Gen 0 |    Gen 1 |    Gen 2 |  Allocated |
|--------- |---------:|---------:|---------:|---------:|------:|--------:|---------:|---------:|---------:|-----------:|
| Version0 | 82.55 ms | 3.107 ms | 9.162 ms | 84.51 ms |  1.00 |    0.00 | 880.0000 | 660.0000 | 360.0000 | 4626.39 KB |
| Version1 | 82.81 ms | 3.103 ms | 9.149 ms | 82.97 ms |  1.02 |    0.17 | 900.0000 | 660.0000 | 380.0000 | 4626.44 KB |
| Version2 | 82.82 ms | 3.092 ms | 9.116 ms | 85.60 ms |  1.02 |    0.16 | 940.0000 | 620.0000 | 400.0000 | 4609.61 KB |
| Version3 | 82.82 ms | 3.095 ms | 9.125 ms | 84.20 ms |  1.02 |    0.17 | 960.0000 | 660.0000 | 420.0000 | 4610.88 KB |
| Version4 | 82.98 ms | 3.050 ms | 8.994 ms | 85.04 ms |  1.02 |    0.17 | 960.0000 | 660.0000 | 420.0000 | 4610.91 KB |
| Version5 | 82.95 ms | 3.048 ms | 8.986 ms | 85.12 ms |  1.02 |    0.17 | 940.0000 | 660.0000 | 420.0000 | 4610.86 KB |
| Version6 | 82.95 ms | 2.961 ms | 8.729 ms | 85.36 ms |  1.02 |    0.16 | 740.0000 | 420.0000 | 220.0000 |  3710.1 KB |
| Version7 | 82.73 ms | 3.238 ms | 9.549 ms | 82.98 ms |  1.02 |    0.20 | 540.0000 | 200.0000 |        - | 2805.17 KB |
| Version8 | 82.83 ms | 3.021 ms | 8.907 ms | 87.16 ms |  1.02 |    0.16 | 540.0000 | 220.0000 |        - | 2836.09 KB |
| Version9 | 82.74 ms | 3.033 ms | 8.944 ms | 87.21 ms |  1.02 |    0.17 |  80.0000 |  20.0000 |        - |   517.5 KB |

10 000 projects

BenchmarkDotNet=v0.12.0, OS=Windows 10.0.19025
Intel Core i7-8750H CPU 2.20GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores
.NET Core SDK=3.1.100-preview3-014645
  [Host]     : .NET Core 3.1.0 (CoreCLR 4.700.19.53102, CoreFX 4.700.19.55104), X64 RyuJIT
  Job-WPDQER : .NET Core 3.1.0 (CoreCLR 4.700.19.53102, CoreFX 4.700.19.55104), X64 RyuJIT

Runtime=.NET Core 3.1  InvocationCount=50  UnrollFactor=1  

|   Method |     Mean |    Error |   StdDev | Ratio | RatioSD |     Gen 0 |     Gen 1 |    Gen 2 | Allocated |
|--------- |---------:|---------:|---------:|------:|--------:|----------:|----------:|---------:|----------:|
| Version0 | 810.4 ms | 12.61 ms | 11.79 ms |  1.00 |    0.00 | 5740.0000 | 2000.0000 | 980.0000 |  53.52 MB |
| Version1 | 814.2 ms | 16.12 ms | 19.80 ms |  1.00 |    0.03 | 5760.0000 | 2000.0000 | 980.0000 |  53.52 MB |
| Version2 | 808.4 ms | 12.23 ms | 11.44 ms |  1.00 |    0.02 | 5820.0000 | 2000.0000 | 980.0000 |   53.5 MB |
| Version3 | 816.5 ms | 15.44 ms | 17.17 ms |  1.01 |    0.02 | 5820.0000 | 2000.0000 | 980.0000 |  53.51 MB |
| Version4 | 808.7 ms | 13.64 ms | 12.76 ms |  1.00 |    0.03 | 5820.0000 | 2000.0000 | 980.0000 |  53.51 MB |
| Version5 | 808.0 ms | 14.11 ms | 13.20 ms |  1.00 |    0.02 | 5800.0000 | 1960.0000 | 960.0000 |  53.51 MB |
| Version6 | 809.7 ms | 11.95 ms | 11.18 ms |  1.00 |    0.02 | 5780.0000 | 2000.0000 | 980.0000 |   35.9 MB |
| Version7 | 808.9 ms | 12.36 ms | 11.56 ms |  1.00 |    0.02 | 5040.0000 | 1520.0000 | 640.0000 |  27.09 MB |
| Version8 | 813.6 ms | 16.17 ms | 16.60 ms |  1.00 |    0.03 | 5120.0000 | 1220.0000 | 540.0000 |  27.39 MB |
| Version9 | 811.0 ms | 15.26 ms | 14.98 ms |  1.00 |    0.03 |  860.0000 |  300.0000 | 140.0000 |   4.66 MB |