Skip to main content

Command Palette

Search for a command to run...

A news feed in C# with ASP.NET Core

A news API in C# on ASP.NET Core, measured: 874 ms per uncached request, 0.9 ms from IMemoryCache.

Updated
•14 min read•View as Markdown
A news feed in C# with ASP.NET Core
A
We build APITube, a news API that returns articles from 300,000+ sources across 177 countries and 59 languages as structured JSON, with sentiment, entities and topics already attached. Posts here are measured experiments, numbers included.

The /api/news endpoint below answers in 874 ms when it has to ask the news API and in 0.9 ms when IMemoryCache already holds the page. That is the number most tutorials leave out, and it is the reason the last third of this post is about caching. The number after it is less comfortable: ten requests arriving at once on a cold cache made ten upstream calls, because GetOrCreateAsync does not lock. Both were measured on 16 September 2026 with the .NET 10.0.401 SDK, Kestrel on localhost, and APITube's live /v1/news/everything endpoint; the CSV sits next to the charts.

A news API in C# is one HttpClient GET with an X-API-Key header, a ReadFromJsonAsync<T> call and a naming policy that maps published_at to PublishedAt. Everything after that is about what happens when the request is slow, wrong or repeated, which is what this post is for: .NET developers who can write a controller and want the version of that request that survives production. Disclosure: APITube is our product, and the free tier at apitube.io is what the code below runs on.

What we measured (one laptop, .NET 10.0.401, three restarts and 33 loads for the cache test):

  • /api/news: 874 ms median on a cache miss, 0.9 ms median on a hit. One GetOrCreateAsync line, a 971× difference.
  • Ten concurrent requests on a cold cache: 10 upstream calls with IMemoryCache, 1 with HybridCache.
  • Same ten rows: 238,524 bytes by default, 7,782 bytes with fl= and seven fields. System.Text.Json parses them in 0.647 ms and 0.065 ms.
  • Ten sequential requests: 893 ms median with a new HttpClient() each time, 706 ms through IHttpClientFactory.
  • A wrong key is HTTP 401 with code ER0202 upstream, a ProblemDetails 401 from our endpoint, and one line on the page. It is never cached.

The build is four files, in this order:

  1. Models/ApiTube.cs: System.Text.Json records and one shared JsonSerializerOptions.
  2. Services/NewsClient.cs: a typed client registered through IHttpClientFactory, with a typed exception for error bodies.
  3. Services/NewsFeedCache.cs: IMemoryCache.GetOrCreateAsync with a five-minute TTL.
  4. Program.cs plus Pages/Index.cshtml: the minimal API endpoint and the Razor page that share the cache.

The request the app keeps making

Every layer below sends the same thing: English articles, newest first, ten per page, and only the fields a list needs. The fl= parameter is what keeps a page under 8 KB; the default response carries the full body, entities and sentiment per row.

curl "https://api.apitube.io/v1/news/everything?\
language.code=en&per_page=2&sort.by=published_at&\
sort.order=desc&fl=id,title,href,published_at,source.domain" \
  -H "X-API-Key: YOUR_API_KEY"

The response, cut to one row, with the long strings shortened:

{
  "status": "ok",
  "page": 1,
  "has_next_pages": true,
  "next_page": "https://api.apitube.io/v1/news/…&page=2",
  "request_id": "f41c2588-78e9-43ca-973f-a24f3e35873d",
  "results": [
    {
      "id": 3083319223,
      "title": "First Army medic exhibits selfless service",
      "href": "https://www.dvidshub.net/news/574898/…",
      "published_at": "2026-09-16T18:35:18.000Z",
      "source": { "domain": "dvidshub.net" }
    }
  ]
}

Three things in there shape the C# code. The keys are snake_case, so the deserializer needs a naming policy. id is 3,083,319,223, which does not fit in an int; the model uses long. And request_id is what you quote to support when something goes wrong, so the error model keeps it too.

Step 1. Models with System.Text.Json

Models/ApiTube.cs holds the records and one shared JsonSerializerOptions. JsonSerializerDefaults.Web gives case-insensitive matching; JsonNamingPolicy.SnakeCaseLower, available since .NET 8, does the published_at to PublishedAt mapping without a single [JsonPropertyName] attribute.

using System.Text.Json;

namespace NewsFeed.Models;

public static class ApiTube
{
    public static readonly JsonSerializerOptions Json =
        new(JsonSerializerDefaults.Web)
        {
            PropertyNamingPolicy =
                JsonNamingPolicy.SnakeCaseLower,
        };
}

public sealed record NewsResponse(
    string Status,
    int Page,
    bool HasNextPages,
    string? NextPage,
    string RequestId,
    List<Article> Results);

public sealed record Article(
    long Id,
    string Title,
    string? Description,
    string Href,
    string? Image,
    DateTimeOffset PublishedAt,
    Source Source);

public sealed record Source(string Domain);

public sealed record ApiError(
    string Status,
    string RequestId,
    List<ApiErrorItem> Errors);

public sealed record ApiErrorItem(
    int Status,
    string Code,
    string Message);

ApiError is the part Microsoft Learn's IHttpClientFactory page never shows: a model for the body the API sends when it says no. Without it a 401 is a status code and nothing else.

Step 2. A typed client with IHttpClientFactory

Services/NewsClient.cs is the typed client. IHttpClientFactory injects a configured HttpClient; the class only knows the path, the fields and what to do with a non-2xx response.

using System.Net;
using System.Net.Http.Json;
using NewsFeed.Models;

namespace NewsFeed.Services;

public sealed class NewsApiException(
    HttpStatusCode status, string code, string message,
    string? requestId, TimeSpan? retryAfter = null)
    : Exception($"{(int)status} {code}: {message}")
{
    public HttpStatusCode Status { get; } = status;
    public string Code { get; } = code;
    public string? RequestId { get; } = requestId;
    public TimeSpan? RetryAfter { get; } = retryAfter;
}

public sealed class NewsClient(HttpClient http)
{
    const string Fields = "id,title,description,href,image,"
        + "published_at,source.domain";

    public async Task<NewsResponse> GetLatestAsync(
        int perPage, CancellationToken ct)
    {
        var url = "v1/news/everything?language.code=en"
            + $"&per_page={perPage}&sort.by=published_at"
            + $"&sort.order=desc&fl={Fields}";

        using var response = await http.GetAsync(url, ct);
        if (response.IsSuccessStatusCode)
        {
            return await response.Content
                .ReadFromJsonAsync<NewsResponse>(
                    ApiTube.Json, ct)
                ?? throw new NewsApiException(
                    response.StatusCode, "EMPTY",
                    "Empty body", null);
        }

        var error = await response.Content
            .ReadFromJsonAsync<ApiError>(ApiTube.Json, ct);
        var first = error?.Errors.FirstOrDefault();
        throw new NewsApiException(
            response.StatusCode,
            first?.Code ?? "UNKNOWN",
            first?.Message ?? "No error body",
            error?.RequestId,
            response.Headers.RetryAfter?.Delta);
    }
}

The registration in Program.cs sets the base address, the key header and a 15-second timeout once, for every NewsClient the container creates. The key comes from configuration, so it lives in dotnet user-secrets locally and in an environment variable named ApiTube__ApiKey in production, never in the source.

var apiKey = builder.Configuration["ApiTube:ApiKey"]
    ?? throw new InvalidOperationException(
        "ApiTube:ApiKey missing");

builder.Services.AddHttpClient<NewsClient>(http =>
{
    http.BaseAddress = new Uri("https://api.apitube.io/");
    http.DefaultRequestHeaders.Add("X-API-Key", apiKey);
    http.Timeout = TimeSpan.FromSeconds(15);
});

Unlike new HttpClient() per request, the factory pools the underlying handler and its TLS connection, which means the second request skips the handshake. We timed ten sequential two-row requests each way: a fresh HttpClient every time had a median of 893 ms (734.8 to 1,155.3 ms); the factory client had a median of 706 ms, with the first, cold call at 1,102 ms and the next nine between 523.5 and 797.3 ms. The 187 ms between the two medians is the price of a new connection to the API's edge, paid on every request.

Step 3. IMemoryCache in front of the client

Services/NewsFeedCache.cs is the whole caching story: one key, a five-minute TTL, and the client call inside the factory delegate.

using Microsoft.Extensions.Caching.Memory;
using NewsFeed.Models;

namespace NewsFeed.Services;

public sealed class NewsFeedCache(
    NewsClient client, IMemoryCache cache)
{
    static readonly TimeSpan Ttl = TimeSpan.FromMinutes(5);

    public Task<NewsResponse?> GetLatestAsync(
        CancellationToken ct) =>
        cache.GetOrCreateAsync("news:latest", entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = Ttl;
            return client.GetLatestAsync(10, ct);
        });
}

One property of GetOrCreateAsync matters for correctness: if the delegate throws, the entry is never committed. We checked it with a wrong key. Four requests produced four upstream calls and four errors, so a failure is never served from the cache as if it were data.

Step 4. The endpoint and the page

Program.cs continues with the cache, Razor Pages, and a minimal API endpoint that turns a NewsApiException into a ProblemDetails response carrying the upstream code and request id.

builder.Services.AddMemoryCache();
builder.Services.AddScoped<NewsFeedCache>();
builder.Services.AddRazorPages();

var app = builder.Build();

app.MapGet("/api/news",
    async (NewsFeedCache feed, CancellationToken ct) =>
{
    try
    {
        return Results.Ok(await feed.GetLatestAsync(ct));
    }
    catch (NewsApiException e)
    {
        return Results.Problem(
            title: e.Code,
            detail: e.Message,
            statusCode: (int)e.Status,
            extensions: new Dictionary<string, object?>
            {
                ["requestId"] = e.RequestId
            });
    }
});

app.MapRazorPages();
app.Run();

The page model in Pages/Index.cshtml.cs calls the same NewsFeedCache, so the page and the JSON endpoint share one cache entry and one upstream call.

using Microsoft.AspNetCore.Mvc.RazorPages;
using NewsFeed.Models;
using NewsFeed.Services;

namespace NewsFeed.Pages;

public sealed class IndexModel(NewsFeedCache feed) : PageModel
{
    public List<Article> Articles { get; private set; } = [];
    public string? Error { get; private set; }

    public async Task OnGetAsync(CancellationToken ct)
    {
        try
        {
            var latest = await feed.GetLatestAsync(ct);
            Articles = latest?.Results ?? [];
        }
        catch (NewsApiException e)
        {
            Error = $"{e.Code} ({e.RequestId})";
        }
    }
}

Pages/Index.cshtml is a loop and an error line:

@page
@model NewsFeed.Pages.IndexModel
<h1>Latest news</h1>
@if (Model.Error is not null)
{
  <p><strong>Feed unavailable:</strong> @Model.Error</p>
}
@foreach (var a in Model.Articles)
{
  <article>
    @if (a.Image is not null) { <img src="@a.Image" alt=""> }
    <div>
      <a href="@a.Href">@a.Title</a><br>
      <small>@a.Source.Domain ·
        @a.PublishedAt.ToString("HH:mm") UTC</small>
    </div>
  </article>
}

dotnet user-secrets set ApiTube:ApiKey YOUR_API_KEY, then dotnet run. The page is 4,771 bytes of HTML and took 991 ms on the first load and 3.0 ms on the second. One detail to expect: our endpoint emits hasNextPages, not has_next_pages, because ASP.NET Core serialises output in camelCase by default while the input options above only apply to what we read.

What a wrong key looks like at each layer

Layer What you get
Upstream HTTP 401, body {"status":"not_ok","request_id":"…","errors":[{"status":401,"code":"ER0202","message":"Invalid API key."}]}
/api/news HTTP 401, {"title":"ER0202","status":401,"detail":"401 ER0202: Invalid API key.","requestId":"…"}
Page HTTP 200 with the line Feed unavailable: ER0202 (request id)

The same class handles 429. Every APITube response carries x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset; on our key the limit reads 50 per minute. When it trips, NewsApiException.RetryAfter holds the Retry-After delta, and the right move for a feed is to keep serving the last good page rather than retry, which is the next section's point.

Ten requests at once

Ten concurrent requests on a cold cache made ten upstream calls with IMemoryCache.GetOrCreateAsync and one with HybridCache.

IMemoryCache.GetOrCreateAsync has no lock around the factory. We restarted the app, fired ten curl requests at /api/news in parallel and counted Start processing HTTP request lines in the client log: ten upstream calls, and the ten responses took between 1.7 and 3.3 s because they queued behind each other at the API. That is a cache stampede, and a feed page linked from a newsletter will produce it on every TTL expiry.

Unlike IMemoryCache, HybridCache, in the Microsoft.Extensions.Caching.Hybrid package since .NET 9, deduplicates concurrent misses for the same key, which means ten simultaneous cold requests make one upstream call instead of ten. The replacement is builder.Services.AddHybridCache() and this class:

public sealed class NewsFeedCache(
    NewsClient client, HybridCache cache)
{
    static readonly HybridCacheEntryOptions Options = new()
    {
        Expiration = TimeSpan.FromMinutes(5),
        LocalCacheExpiration = TimeSpan.FromMinutes(5),
    };

    public async Task<NewsResponse?> GetLatestAsync(
        CancellationToken ct) =>
        await cache.GetOrCreateAsync(
            "news:latest",
            async token =>
                await client.GetLatestAsync(10, token),
            Options,
            cancellationToken: ct);
}

Same test, same ten requests: one upstream call, and all ten responses came back in 1.06 s. If the feed has one key and one server, IMemoryCache is fine and the stampede costs you ten requests per expiry. With ten category feeds and a 50-per-minute limit, it costs you the limit.

Ask for seven fields, not two hundred

The same ten rows are 238,524 bytes without fl= and 7,782 bytes with seven fields; System.Text.Json parses them in 0.647 ms and 0.065 ms.

The default response for ten articles was 238,524 bytes. With fl=id,title,description,href,image,published_at,source.domain the same ten rows were 7,782 bytes, 30.6× smaller. System.Text.Json needed a median of 0.647 ms to parse the big one and 0.065 ms for the small one, over 50 runs. Neither number matters next to an 874 ms round trip, but 230 KB per page miss adds up on a metered host, and the records above only work because they ignore what they do not declare; JsonSerializerDefaults.Web skips unknown properties, so the full response still deserialises if you forget fl=.

How long to cache: TTL against the rate limit

The cache TTL is not a taste decision once the API has a limit. With stampede protection, the worst case is one upstream call per cache key per TTL, so:

upstream calls per minute = cache keys × 60 / TTL in seconds

That has to stay under the limit, 50 on our key, with room for a retry. The table is computed from the formula, not measured:

Cache keys (feeds) TTL 5 s TTL 30 s TTL 60 s TTL 300 s
1 12 per min 2 1 0.2
10 120, over 20 10 2
50 600, over 100, over 50, at the limit 10

The threshold is TTL ≥ 1.2 s × keys to stay under 50 per minute, and without stampede protection you multiply the keys by the concurrency you expect at expiry. Five minutes for one feed is 0.2 calls per minute, which is why the code above uses it; a breaking-news page that wants 30 s can afford it up to about 40 feeds.

The numbers, side by side

Thirty-three loads of /api/news: three cache misses between 829 and 1,079 ms, thirty hits between 0.7 and 2.3 ms.

What Runs Median Range
/api/news, cache miss 3 874 ms 829 to 1,079 ms
/api/news, cache hit 30 0.9 ms 0.7 to 2.3 ms
Razor page, cold then warm 1 + 1 991 ms, 3.0 ms
new HttpClient() per request 10 893 ms 735 to 1,155 ms
IHttpClientFactory client 10 706 ms 524 to 1,102 ms
10 concurrent cold requests, IMemoryCache 1 run 10 upstream calls responses 1.7 to 3.3 s
10 concurrent cold requests, HybridCache 1 run 1 upstream call responses 1.06 s
10 rows, default vs fl= 1 + 1 238,524 vs 7,782 bytes parse 0.647 vs 0.065 ms

We restarted the app before each miss and loaded the endpoint eleven times per round with curl on the same machine; the JSON our endpoint returned was 5,351 bytes in all 33 loads, so the spread is the round trip to the API, not the payload. Three misses and one stampede run are enough to show the orders of magnitude and not enough to rank anything finer. The CSV and the plotting script are in the folder next to the charts.

FAQ

How do we call a REST API in C#?

The way to call a REST API in C# is a typed client registered with AddHttpClient<T> and a GetAsync on the injected HttpClient, because the factory pools connections and applies the base address, headers and timeout once. In our test the factory client answered in 706 ms median against 893 ms for a new HttpClient() per request.

How do we deserialize JSON from HttpClient in .NET?

The way to deserialize JSON from HttpClient is response.Content.ReadFromJsonAsync<T>(options) with JsonNamingPolicy.SnakeCaseLower when the API uses snake_case, because it maps published_at to PublishedAt without attributes. Declare only the properties you use; JsonSerializerDefaults.Web ignores the rest.

Should we use IHttpClientFactory or new HttpClient()?

Use IHttpClientFactory. A new HttpClient() per request opens a new connection each time and exhausts sockets under load; the factory reuses the handler. Measured on ten sequential requests, the difference was 187 ms per request in median, all of it connection setup.

How do we cache API responses in ASP.NET Core?

The way to cache API responses in ASP.NET Core is IMemoryCache.GetOrCreateAsync(key, factory) with an absolute expiration, because the factory runs only when the key is missing or expired and a thrown exception is never cached. Our endpoint went from 874 ms to 0.9 ms. Use HybridCache if concurrent misses are possible; it made one upstream call where IMemoryCache made ten.

How do we handle API errors in HttpClient?

The way to handle API errors in HttpClient is to read the error body into a typed model on any non-success status and throw an exception that carries the API's code, its request id and Retry-After, because EnsureSuccessStatusCode discards all three. APITube's 401 carries ER0202 and a request id; both reach the page in one line.

If the post leaves one habit behind, it should be this: put the cache in before the retry policy. The cache is what turns 874 ms into 0.9 ms and the rate limit into the arithmetic above; a retry handler on an uncached feed only makes a struggling upstream slower, and a burst on a cold cache still needs HybridCache to stay at one call.

APITube is the API we used here; the free tier at apitube.io covers everything in this post.

Resources