# Replicant: an HttpClient wrapper that caches responses to disk

> Replicant is a .NET wrapper around HttpClient that stores responses on disk, honours the caching headers a server sends, and evicts old entries by last access time. It suits a .NET service that fetches the same URLs repeatedly, and its eviction policy is driven by a max entry count rather than by time.

**SimonCropp/Replicant** — A wrapper for HttpClient that caches to disk. Cached files, over the max specified, are deleted based on the last access times.

- Repository: https://github.com/SimonCropp/Replicant
- Stars: 608 · Forks: 12
- Language: C#
- License: MIT
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/simoncropp-replicant

## The cache is on disk and evicted by last access time

Replicant is a wrapper for HttpClient that caches to disk, and the eviction rule is stated in one sentence: cached files over the maximum specified are deleted based on the last access times. That is the whole design in a phrase, and it is a different policy from the one most people expect from an HTTP cache. There is no background sweeper deciding what expired, there is no time to live that you set, and there is no revalidation timer. Instead the cache has a ceiling on how many entries it holds, and when it is over that ceiling the least recently accessed files go. For a workload that reads a bounded set of URLs, that behaves the way you want, because the hot set stays on disk. For a workload that reads an unbounded stream of URLs, it degenerates into a least recently used cache, which is fine, but it is not a cache that knows a given URL went stale.

## Server caching headers decide what gets stored

The list of headers the library respects in its caching decisions is the interesting part of the design, and it covers the usual set. Expires is the first, then Cache-Control max-age, then the two negative Cache-Control directives no-store and no-cache. Last-Modified and ETag are also handled, which is what makes conditional requests possible. The practical consequence is that Replicant is not a cache that overrides the origin server. If a server says not to store the response, Replicant does not store it, and if a server says the response is fresh for an hour, that is the freshness window it uses. This is the right default for an HTTP client, because it means the caching behaviour of your service follows the caching behaviour of the APIs you call rather than being a policy you have to replicate. It also means a badly configured upstream can silently disable caching for you, which is worth checking when you wonder why the cache is always cold.

## The default static instance is one line and writes to the temp folder

The quickest way to see what the library does is the static default instance, and the example in the documentation is a single call.

```cs
var content = await HttpCache.Default.DownloadAsync("https://httpbin.org/status/200");
```

The documentation says plainly that this caches to a Replicant folder under the temp directory. That is convenient for a test and a poor default for a server, because temp folders get cleaned, and a cache that is wiped on a schedule is not a cache. When you construct an instance yourself you choose the directory, and the documented constructor takes a directory, an optional HttpClient and a max entries value.

```cs
var httpCache = new HttpCache(
    cacheDirectory,
    // omit for default new HttpClient()
    new HttpClient
    {
        Timeout = TimeSpan.FromSeconds(30)
    },
    // omit for the default of 1000
    maxEntries: 10000);

// Dispose when finished
await httpCache.DisposeAsync();
```

Two defaults are named in the comments. The HttpClient argument is optional and a new HttpClient is used if you omit it, and the entry ceiling defaults to 1000. The documentation also says an instance of HttpCache should be long running, which is the practical instruction to register it once and keep it, rather than constructing one per request and throwing away its index.

## Registering it as a singleton, with or without HttpClientFactory

With dependency injection the documentation says to add HttpCache as a singleton, and the example registers the instance against a service collection.

```cs
var services = new ServiceCollection();
services.AddSingleton(_ => new HttpCache(cacheDirectory));

using var provider = services.BuildServiceProvider();
var httpCache = provider.GetRequiredService<HttpCache>();
await Assert.That(httpCache).IsNotNull();
```

There is a second variant that uses HttpClientFactory, and it passes a client factory delegate rather than a fixed HttpClient so that the underlying client is managed by the factory.

```cs
var services = new ServiceCollection();
services.AddHttpClient();
services.AddSingleton(_ =>
{
    var clientFactory = _.GetRequiredService<IHttpClientFactory>();
    return new HttpCache(cacheDirectory, clientFactory.CreateClient);
});

using var provider = services.BuildServiceProvider();
var httpCache = provider.GetRequiredService<HttpCache>();
await Assert.That(httpCache).IsNotNull();
```

The distinction is worth understanding rather than copying. The first form owns its HttpClient, which is fine but means you manage the handler lifetime yourself. The second hands the creation of the client to the factory, so handler lifetime, DNS refresh and pool management follow the standard .NET factory behaviour, which is usually what you want in a service that also uses resilience policies.

## ReplicantHandler plugs into the pipeline instead

The other integration style is a DelegatingHandler, and it is the one that composes with the rest of an HttpClient pipeline.

```cs
var handler = new ReplicantHandler(cacheDirectory)
{
    InnerHandler = new HttpClientHandler()
};
using var client = new HttpClient(handler);
var response = await client.GetAsync("https://example.com");
```

With HttpClientFactory the handler is added with AddHttpMessageHandler.

```cs
var services = new ServiceCollection();
services.AddHttpClient("CachedClient")
    .AddHttpMessageHandler(() => new ReplicantHandler(cacheDirectory));
```

And to share one cache, and therefore one purge timer, across several named clients, the documentation says to register a ReplicantCache as a singleton.

```cs
var services = new ServiceCollection();
services.AddReplicantCache(cacheDirectory);
services.AddHttpClient("CachedClient")
    .AddReplicantCaching();
```

The shared cache version matters for a service that talks to the same API through several named clients, because otherwise each named client keeps its own view and the disk files are contended by independent purges.

## Retries are built in, and the documentation argues against adding Polly

The documentation takes an unusual position here, questioning whether you need a resilience library at all. It says Replicant already ships with retry using exponential backoff through the maxRetries parameter on both HttpCache and ReplicantHandler, and that if retries on transient 5xx, 408 and network exceptions are all you need, you should use that and skip the extra dependency. It says to reach for Microsoft.Extensions.Http.Resilience when you also want circuit breaking, timeouts, hedging, rate limiting or fine grained control over which responses are retried. Since ReplicantHandler is a standard DelegatingHandler, it composes with that package, which is built on Polly. The ordering advice is the part with teeth: always place the cache outside the resilience pipeline, so that cache hits short-circuit immediately and never consume retry attempts, the timeout budget or circuit breaker throughput, and so the circuit breaker only observes real upstream failures.

## Package, licence and the shape of the releases

Replicant ships as a NuGet package and the repository is MIT licensed, with a license.txt at the top level of the tree. The package is what most people will consume, since the repository holds the src directory and little else, with a global.json pinning the SDK and a claude.md alongside the usual editor and repository files. The release history is short and worth reading before you pin a version, because the version numbers jump. The recent releases are 3.3.0 from 2026-04-16 and 2.0.0 from 2023-03-22, and the documentation points to the repository milestones for the release notes rather than keeping a changelog file. The last push to the repository was on 2026-10-14, so the 3.3.0 line is what is being worked on. There is no separate documentation site in the repository listing, which means the readme, the milestone notes and the XML documentation in the package are what you have to work from.

## Conclusion

Replicant fits a .NET service that calls the same third party endpoints over and over, where a disk cache in front of HttpClient removes a network round trip without asking you to reimplement cache header handling, and it fits a team that wants a shared cache across several named HttpClient instances. It does not fit a workload that needs a time based expiry policy or a cache shared between machines, because the eviction described here is driven by an entry count and by last access times on local disk. Before you adopt it, decide where the cache directory lives, since the default writes to the temp folder and that is not a place you want your cache on a server, and check how your upstream servers set Expires and Cache-Control, since the library honours those headers rather than overriding them.

## FAQ

### What does Replicant do and where does it cache files?

It is a wrapper for HttpClient that caches responses to disk, deleting cached files over the specified maximum based on their last access times. The static default instance caches to a Replicant folder under the temp directory, while an instance you construct yourself takes the cache directory you give it.

### Which HTTP caching headers does Replicant respect?

Expires, Cache-Control max-age, Cache-Control no-store, Cache-Control no-cache, Last-Modified and ETag are the headers the caching decisions are based on. That means the origin server's caching directives are honoured rather than overridden.

### How do I register Replicant with dependency injection?

The documentation says to add HttpCache as a singleton, for example services.AddSingleton(_ => new HttpCache(cacheDirectory)). There is also a variant that passes clientFactory.CreateClient so the HttpClient is managed by HttpClientFactory, and a ReplicantHandler that can be added to a named client with AddHttpMessageHandler.

### How many entries does a Replicant cache keep by default?

The maxEntries parameter defaults to 1000, and it is passed to the HttpCache constructor. Cached files over that maximum are deleted based on their last access times, so the policy is a count with least recently used eviction rather than a time to live.

### Do I need Microsoft.Extensions.Http.Resilience with Replicant?

Not for retries alone, because Replicant has built-in retry with exponential backoff through the maxRetries parameter on both HttpCache and ReplicantHandler. The documentation suggests the resilience package for circuit breaking, timeouts, hedging, rate limiting or fine grained retry control, and to place the cache outside the resilience pipeline so cache hits do not consume retry attempts.

## Sources

- [Issues](https://github.com/SimonCropp/Replicant/issues)
- [License: MIT](https://github.com/SimonCropp/Replicant/blob/main/LICENSE)
- [README](https://github.com/SimonCropp/Replicant/blob/main/README.md)
- [Releases](https://github.com/SimonCropp/Replicant/releases)
- [SimonCropp/Replicant on GitHub](https://github.com/SimonCropp/Replicant)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/simoncropp-replicant
