Skip to main content

Sessions That Survive the Next Deploy: ASP.NET Core Session State on Zaris

· 11 min read
Clustron Team
Distributed Systems Engineering

Session state is the thing you forget is stateful. You call HttpContext.Session.SetString(...) to stash a cart, a wizard step, an anti-forgery nonce, a "you just added 3 items" flash message — and on a single process it just works, because the default session store is a dictionary in that process's memory. Then you add a second instance behind a load balancer, and it stops working in a way that is maddening to debug: most requests are fine, but every so often a user's cart is empty, their multi-step form resets, or they're bounced to the login page. The reason is simple once you see it — their session lives in instance A's memory, and that request landed on instance B, which has never heard of them.

The usual patch is sticky sessions: configure the load balancer to pin each client to the instance that first served them. It makes the symptom go away, but it doesn't make the state shared — it just hides the fact that it isn't. Sticky routing means your load balancing is now lopsided, a rolling deploy logs out everyone pinned to the pod being replaced, autoscaling-in drops whoever was pinned to the terminated instance, and a single crash takes every session on that node with it. You haven't made sessions durable across instances; you've made each instance a single point of failure for its own users.

The real fix is to put the session bytes somewhere every instance can see, so routing stops mattering. Zaris ships exactly that as a first-class ASP.NET Core integration — Clustron.Zaris.AspNetCore.Session — and it is one call at startup. This post shows the whole wiring, verified against the shipping package, and the one thing it does that a plain distributed cache does not: slide the idle timeout on every request.

What a session store needs that a cache does not​

It's tempting to say "sessions are just a cache" and register any IDistributedCache. That gets you most of the way, but session state has one semantic that a general cache doesn't model on its own: the sliding idle timeout. A session should stay alive as long as the user is active and expire a fixed interval after they stop. Every request that so much as reads the session needs to push the expiry out again. A plain Set with a TTL gives you an absolute lifetime — the session dies 20 minutes after it was created, even if the user clicked something 19 minutes in.

ASP.NET Core's session middleware already knows how to ask for this: when a request touches a session but doesn't modify it, DistributedSession calls Refresh on its backing store, expecting the store to re-arm the expiry. The Zaris session store implements that Refresh as a read-and-re-put with a fresh TTL, so the idle window genuinely slides:

// Inside the Zaris session store: Refresh re-arms the TTL on every request that touches the session.
public async Task RefreshAsync(string key, CancellationToken token = default)
{
var client = await GetClientAsync();
var k = Key(key); // apply the "zaris:session:" prefix
var current = await client.GetAsync<byte[]>(k, ct: token);
if (current.IsSuccess && current.Value != null)
await client.PutAsync(k, current.Value, Put.WithTtl(_idleTimeout), ct: token);
}

That's the difference between a session store and a bag of bytes with a timer. The other thing it does is namespace every key behind a prefix (zaris:session: by default), so sessions can share a Zaris store with other data without colliding.

The wiring: one call​

Point the app at a store with a connection string the same way you would for anything else, then register the session store. zariss:// is the TLS scheme (the extra s, like https); list more than one host and the client fails over between them.

// appsettings.json
{
"ConnectionStrings": {
"Sessions": "zariss://zaris-a.example.com:7861,zaris-b.example.com:7861/?store=sessions&token=env:ZARIS_TOKEN"
}
}

One call wires Zaris as the session backing store. If you give it a ConnectionString, it registers the Zaris client for you; if you've already registered a store under StoreName (via AddClustronZaris(...)), you can omit the connection string and it reuses that one.

using Clustron.Zaris.AspNetCore.Session;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddZarisSession(options =>
{
options.StoreName = "sessions";
options.ConnectionString = builder.Configuration.GetConnectionString("Sessions");
options.KeyPrefix = "zaris:session:"; // namespaces session keys in a shared store
options.IdleTimeout = TimeSpan.FromMinutes(30);
options.IOTimeout = TimeSpan.FromMinutes(1);
options.CookieName = ".Zaris.Session";
options.CookieIsEssential = true; // exempt from consent-based cookie suppression
});

var app = builder.Build();

app.UseSession(); // same as always — the middleware now reads and writes Zaris

That's the entire integration. Everything downstream is stock ASP.NET Core: AddZarisSession registers the ISessionStore, configures the session cookie and idle/IO timeouts, and app.UseSession() puts the middleware in the pipeline. The IdleTimeout here is the sliding window; IOTimeout caps how long a load-or-commit may take before the request gives up on the store rather than hanging.

Using it: nothing changes​

The payoff is that your application code doesn't know Zaris exists. You write against HttpContext.Session exactly as you always have — the standard ISession surface:

app.MapPost("/cart/add", (HttpContext ctx, int sku) =>
{
var cart = ctx.Session.GetString("cart") is { } json
? JsonSerializer.Deserialize<List<int>>(json)!
: new List<int>();

cart.Add(sku);
ctx.Session.SetString("cart", JsonSerializer.Serialize(cart));
return Results.Ok(cart.Count);
});

app.MapGet("/cart", (HttpContext ctx) =>
{
var count = ctx.Session.GetString("cart") is { } json
? JsonSerializer.Deserialize<List<int>>(json)!.Count
: 0;
return Results.Text($"{count} item(s)");
});

GetString/SetString, GetInt32/SetInt32, Keys, Remove, Clear — all of it is the framework's own ISession API. The only change you made was at startup. Now a POST /cart/add can land on instance A and the follow-up GET /cart on instance C, and the count is right, because both instances read the same bytes out of the one place they live. The session cookie carries only an opaque session id; the data itself never leaves the cluster.

Where the session actually lives​

Browsercookie: session-idany instanceno sticky routingApp instance AApp instance BApp instance CZaris storezaris:session:<id>cart · step · flagsTTL re-arms → slides

The browser holds only an opaque id in its cookie. The load balancer is free to send each request wherever it likes — there is nothing to pin, because no instance owns the session. Whichever instance serves the request loads the session bytes from the one entry that holds them, keyed by the session id, and writes any changes back to that same entry. Because the entry carries a TTL that is re-armed on every request, the session stays alive while the user is active and expires on its own a fixed interval after they go quiet. Restart an instance, roll a deploy, scale in — the sessions were never in that instance to begin with.

Trade-offs, and the honest edges​

A session is one serialized blob, read and rewritten per request. ASP.NET Core sessions are a bag of byte-arrays serialized together, loaded on first access and committed at the end of the request. That's cheap for a cart or a few flags; it is the wrong shape for megabytes of state or for values that change on a fast inner loop. Keep sessions small and put large or hot data in its own keys with the native client.

Concurrent requests on the same session are last-writer-wins. Two tabs posting at the same moment each load the session, mutate their copy, and commit — the later commit overwrites the earlier one's changes for keys it didn't touch. This is how ASP.NET Core sessions behave on any distributed store, not a Zaris quirk; the framework does not lock a session across concurrent requests. If a piece of state needs atomic concurrent updates (a running total, a counter), model it as a Zaris counter or a CAS loop, not as a session value.

The idle window slides with a read-and-re-put, not a bare TTL touch. Refresh reads the current bytes and writes them back with a fresh TTL, rather than issuing a TTL-only reset. That keeps sliding correct and identical on every deployment, embedded in-process included, at the cost of one extra round trip on a request that only reads the session. For the common case — a request that reads and also writes the session — the commit already carries the fresh TTL and there's no extra hop.

IOTimeout is a real knob, not decoration. If the store is briefly unreachable, you do not want every request to hang on it. IOTimeout bounds how long a load or commit may take; past that, the request proceeds without the session rather than blocking. Set it with the same care you'd give any dependency timeout.

When to use this versus RESP or a generic cache. If you're migrating an app that already stores sessions in Redis, Zaris speaks the Redis protocol and that path keeps working. AddZarisSession is the first-class .NET surface: it implements ASP.NET Core's ISessionStore directly, honours the sliding idle timeout, and namespaces keys for you. And if you only need a general IDistributedCache or a two-tier HybridCache, those ship as their own Zaris packages — the session store is the one specialized for session semantics.

Wrap-up​

In-memory session state is a single point of failure wearing a convenience's clothing: it works on one process, breaks quietly on two, and sticky sessions only paper over the break. Moving the bytes into a shared cluster is what actually fixes it — any instance serves any request, and a deploy or a scale-in stops logging people out. Zaris makes that a one-line change: AddZarisSession, point it at a store, keep writing HttpContext.Session as you always have. The idle timeout slides, the keys stay namespaced, and the session outlives the instance that happened to serve the last request.

The session store is one of three ASP.NET Core packages Zaris ships; the Session sample in the Zaris samples repository is this post as a runnable app, down to a --selftest that proves a value persists across two requests.