Home β€Ί .NET on AWS β€Ί Using Valkey (ElastiCache) with .NET

Using Valkey (ElastiCache) with .NET

In-memory caching and data storage with Valkey on ElastiCache from .NET: StackExchange.Redis, caching patterns, and CDK setup.

What Is Valkey / ElastiCache?

Valkey is the open-source successor to Redis, now the default engine for Amazon ElastiCache. It's an in-memory data store. Sub-millisecond reads, support for strings, hashes, lists, sets, sorted sets, and streams.

After Redis changed its license in 2024, AWS (along with Google, Oracle, and the Linux Foundation) forked the project as Valkey. AWS has since invested heavily in performance: Valkey 8+ includes multi-threaded I/O, optimized memory allocation, and throughput improvements that outperform the last open-source Redis release. It's not just a fork. It's actively getting faster.

On AWS, you run Valkey through ElastiCache (managed clusters) or MemoryDB (durable, Redis-compatible with disk persistence). For most caching use cases, ElastiCache is the right choice.

When to Use It

  • Caching: Reduce DynamoDB/RDS read latency and cost by caching hot data
  • Session storage: Store user sessions outside your application for horizontal scaling
  • Rate limiting: Use atomic increments with TTL for API rate limiting
  • Leaderboards: Sorted sets for ranked data
  • Pub/Sub: Simple real-time messaging between services within a VPC

If you only need basic key-value caching with automatic expiry, DynamoDB DAX might be simpler (no VPC networking to manage). But for anything beyond simple caching (sorted sets, pub/sub, Lua scripting, complex data structures) Valkey is the answer.

.NET Client: StackExchange.Redis

The standard .NET client for Redis/Valkey is StackExchange.Redis. It's mature, high-performance, and handles connection pooling and reconnection automatically.

Basic setup

using StackExchange.Redis;

// ConnectionMultiplexer is thread-safe β€” create once, reuse
var connection = await ConnectionMultiplexer.ConnectAsync(new ConfigurationOptions
{
    EndPoints = { { "my-cluster.abc123.use1.cache.amazonaws.com", 6379 } },
    Ssl = true,
    AbortOnConnectFail = false,
    ConnectRetry = 3,
    ConnectTimeout = 5000,
});

var db = connection.GetDatabase();

Register as a singleton in DI

builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
    ConnectionMultiplexer.Connect(new ConfigurationOptions
    {
        EndPoints = { { Environment.GetEnvironmentVariable("CACHE_ENDPOINT")!, 6379 } },
        Ssl = true,
        AbortOnConnectFail = false,
    })
);

Common Patterns

Cache-aside (read-through)

public async Task<UserProfile?> GetUserProfileAsync(string userId)
{
    var db = _connection.GetDatabase();
    var cacheKey = $"user:{userId}:profile";
    
    // Try cache first
    var cached = await db.StringGetAsync(cacheKey);
    if (cached.HasValue)
    {
        return JsonSerializer.Deserialize(cached!, AppJsonContext.Default.UserProfile);
    }
    
    // Cache miss β€” load from DynamoDB
    var profile = await _dynamoRepo.GetProfileAsync(userId);
    if (profile is not null)
    {
        // Cache for 5 minutes
        await db.StringSetAsync(
            cacheKey,
            JsonSerializer.Serialize(profile, AppJsonContext.Default.UserProfile),
            TimeSpan.FromMinutes(5)
        );
    }
    
    return profile;
}

Rate limiting

public async Task<bool> IsRateLimitedAsync(string clientId, int maxRequests, TimeSpan window)
{
    var db = _connection.GetDatabase();
    var key = $"ratelimit:{clientId}";
    
    var count = await db.StringIncrementAsync(key);
    if (count == 1)
    {
        // First request in window β€” set expiry
        await db.KeyExpireAsync(key, window);
    }
    
    return count > maxRequests;
}

Distributed locking

public async Task<bool> TryAcquireLockAsync(string resource, string owner, TimeSpan expiry)
{
    var db = _connection.GetDatabase();
    var key = $"lock:{resource}";
    
    return await db.StringSetAsync(key, owner, expiry, When.NotExists);
}

public async Task ReleaseLockAsync(string resource, string owner)
{
    var db = _connection.GetDatabase();
    var key = $"lock:{resource}";
    
    // Only release if we own it (Lua script for atomicity)
    var script = @"
        if redis.call('get', KEYS[1]) == ARGV[1] then
            return redis.call('del', KEYS[1])
        else
            return 0
        end";
    
    await db.ScriptEvaluateAsync(script, new RedisKey[] { key }, new RedisValue[] { owner });
}

CDK Setup (C#)

using Amazon.CDK;
using Amazon.CDK.AWS.ElastiCache;
using Amazon.CDK.AWS.EC2;

// Valkey requires VPC placement
var cacheSubnetGroup = new CfnSubnetGroup(this, "CacheSubnetGroup", new CfnSubnetGroupProps
{
    Description = "Subnets for ElastiCache",
    SubnetIds = vpc.SelectSubnets(new SubnetSelection
    {
        SubnetType = SubnetType.PRIVATE_WITH_EGRESS,
    }).SubnetIds,
});

var cacheSecurityGroup = new SecurityGroup(this, "CacheSG", new SecurityGroupProps
{
    Vpc = vpc,
    Description = "Security group for ElastiCache Valkey",
    AllowAllOutbound = false,
});

// Allow Lambda/ECS to connect
cacheSecurityGroup.AddIngressRule(
    appSecurityGroup,
    Port.Tcp(6379),
    "Allow app access to cache"
);

// Serverless ElastiCache (auto-scaling, no shard management)
var cache = new CfnServerlessCache(this, "AppCache", new CfnServerlessCacheProps
{
    Engine = "valkey",
    ServerlessCacheName = "my-app-cache",
    SecurityGroupIds = new[] { cacheSecurityGroup.SecurityGroupId },
    SubnetIds = vpc.SelectSubnets(new SubnetSelection
    {
        SubnetType = SubnetType.PRIVATE_WITH_EGRESS,
    }).SubnetIds,
    CacheUsageLimits = new CfnServerlessCache.CacheUsageLimitsProperty
    {
        DataStorage = new CfnServerlessCache.DataStorageProperty
        {
            Maximum = 5,
            Unit = "GB",
        },
    },
});

Lambda Considerations

Using Valkey from Lambda requires VPC-attached functions, which historically added cold start latency. With Lambda's recent VPC improvements, the cold start penalty is minimal (adds ~200ms vs the old 5-10s).

However, connection management in Lambda is different:

  • Don't create a new connection per invocation: initialize ConnectionMultiplexer outside the handler
  • Handle reconnection gracefully: frozen Lambda instances may have stale connections
  • Consider connection limits: hundreds of concurrent Lambda invocations means hundreds of connections to your cache cluster

For high-concurrency Lambda workloads, ElastiCache Serverless handles connection pooling better than classic clusters.

Further Reading

Looking for hands-on help? View my .NET on AWS services β†’

Need caching in your architecture?

Drop me a message β€” I typically respond within one business day.