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
ConnectionMultiplexeroutside 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 β