This is part of the series of introducing Vanilla ASP.NET Web Forms Architecture. Read more about this at the main menu: Main Article: [Introducing Vanilla ASP.NET Web Forms Architecture]
Main Content: Page Content Caching Strategy in Vanilla ASP.NET Web Forms:
- Part 1: Lightning Fast Page Caching Strategy for High Traffic Performance Vanilla ASP.NET Web Forms
- Part 2: Building Redis-Like Distributed Cache with Vanilla ASP.NET Web Forms
Part 2: Building Redis-Like Distributed Cache with Vanilla ASP.NET Web Forms
Redis is basically a shared memory cache that multiple servers can access. Think of it as moving your ConcurrentDictionary
outside of your application into a separate service. But since if we going to implement a two tier Apps Architecture (App 1 – Cache Builder and App 2 – Cache Server), we can directly use the App 1 to serve as Redis. App 1 is ASP.NET Web Forms, natively supported by Windows IIS infrastructure and engine, which will be highly efficient and more direct control more customizable behaviour than using 3rd party Redis service, utilizing the powerful lightning fast backend processing of Vanilla ASP.NET Web Forms.
This two-app architecture actually avoids needing Redis because:
- App 1 builds cache → saves to database/files
- App 2 instances read from shared cached/database/files
- This gives you distributed cache benefits without Redis!
The Architecture
We’ll use our two-application architecture where App 1 serves as both cache builder AND cache server:
App 2 (www.myapp.com) - Multiple Worker Processes
↓
Local ConcurrentDictionary (in-process cache)
↓ (version check or cache miss)
App 1 (api.myapp.com) - Single Worker Process
↓
L1: ConcurrentDictionary (memory cache)
↓ (miss)
L2: File System Cache
↓ (miss)
L3: Database Cache Table
↓ (miss)
Rebuild from source data
The Host for the Apps
===================
App 1:
===================
For public access:
https://api.myapp.com
routed page: /pages/api/apiArticle.aspx
https://api.myapp.com/apiArticle?action=get_cache&key=article_123
https://api.myapp.com/apiArticle?action=get_version&key=article_123
For internal local access, bind with localhost with custom port
http://localhost:61235
routed page: /pages/api/apiMaintenance.aspx
http://localhost:61235/apiMaintenance?action=rebuild&key=article_123
http://localhost:61235/apiMaintenance?action=clear_all_cache
http://localhost:61235/apiMaintenance?action=clear&key=article_123
===================
App 2:
===================
Public access only:
https://www.myapp.com
App 1 – The Cache Server
First, let’s set up the global timers in App 1:
public class Global : System.Web.HttpApplication
{
public static Timer cacheCleanupTimer;
public static Timer rebuildFrontPageTimer;
// Global cache storage
public static ConcurrentDictionary<string, CacheItem> memoryCache = new ConcurrentDictionary<string, CacheItem>();
// Flag for front page rebuild
public static bool needRebuildFrontPage = false;
protected void Application_Start(object sender, EventArgs e)
{
// Check every hour for 24-hour inactive items
cacheCleanupTimer = new Timer(
CleanupInactiveCache,
null,
TimeSpan.FromMinutes(3), // First run after 3 minutes
TimeSpan.FromHours(1) // Then every hour
);
// Check front page rebuild flag every 5 minutes
rebuildFrontPageTimer = new Timer(
CheckFrontPageRebuild,
null,
TimeSpan.FromMinutes(1), // First run after 1 minute
TimeSpan.FromMinutes(5) // Then every 5 minutes
);
}
static void CleanupInactiveCache(object state)
{
var expiredTime = DateTime.Now.AddHours(-24);
var keysToRemove = memoryCache
.Where(x => x.Value.LastAccess < expiredTime)
.Select(x => x.Key)
.ToList();
foreach (var key in keysToRemove)
{
memoryCache.TryRemove(key, out _);
}
}
static void CheckFrontPageRebuild(object state)
{
if (needRebuildFrontPage)
{
RebuildFrontPageCache();
needRebuildFrontPage = false;
}
}
}
The cache item class to track version and access:
public class CacheItem
{
public int Version { get; set; }
public string Html { get; set; }
public DateTime LastAccess { get; set; }
}
Now the public API endpoint for App 2 to access:
// apiArticle.aspx.cs
public partial class apiArticle : System.Web.UI.Page
{
protected void Page_Load(object sender, EventArgs e)
{
string action = Request["action"] + "";
switch (action.ToLower())
{
case "get_cache":
GetCache();
break;
case "get_version":
GetVersion();
break;
default:
Response.StatusCode = 400;
Response.Write("Invalid action");
break;
}
}
void GetCache()
{
string key = Request["key"] + "";
// L1: Check memory cache
if (Global.memoryCache.TryGetValue(key, out CacheItem cached))
{
cached.LastAccess = DateTime.Now;
Response.Write(cached.Html);
return;
}
// L2: Check file cache
string filePath = Server.MapPath($"~/App_Data/cache/{key}.html");
if (File.Exists(filePath))
{
string html = File.ReadAllText(filePath);
// Get version from database
int version = GetVersionFromDB(key);
// Promote back to memory
Global.memoryCache[key] = new CacheItem
{
Version = version,
Html = html,
LastAccess = DateTime.Now
};
Response.Write(html);
return;
}
// L3: Check database cache
var dbCache = GetFromDatabaseCache(key);
if (dbCache != null)
{
// Promote to file cache
File.WriteAllText(filePath, dbCache.Html);
// Promote to memory cache
Global.memoryCache[key] = new CacheItem
{
Version = dbCache.Version,
Html = dbCache.Html,
LastAccess = DateTime.Now
};
Response.Write(dbCache.Html);
return;
}
// Cache miss - rebuild
string newHtml = RebuildArticleCache(key);
int newVersion = DateTime.Now.Ticks.GetHashCode();
// Store in all 3 tiers
StoreInAllTiers(key, newHtml, newVersion);
Response.Write(newHtml);
}
void GetVersion()
{
string key = Request["key"] + "";
if (Global.memoryCache.TryGetValue(key, out CacheItem cached))
{
Response.Write(cached.Version);
return;
}
// Get from database if not in memory
int version = GetVersionFromDB(key);
Response.Write(version);
}
void StoreInAllTiers(string key, string html, int version)
{
// L1: Memory
Global.memoryCache[key] = new CacheItem
{
Version = version,
Html = html,
LastAccess = DateTime.Now
};
// L2: File
string filePath = Server.MapPath($"~/App_Data/cache/{key}.html");
File.WriteAllText(filePath, html);
// L3: Database
SaveToDatabaseCache(key, html, version);
}
}
The internal maintenance API (localhost only):
// apiMaintenance.aspx.cs
public partial class apiMaintenance : System.Web.UI.Page
{
protected void Page_Load(object sender, EventArgs e)
{
// Security: Only allow localhost access
if (!Request.IsLocal)
{
Response.StatusCode = 403;
Response.Write("Forbidden");
return;
}
string action = Request["action"] + "";
switch (action.ToLower())
{
case "rebuild":
RebuildCache();
break;
case "clear":
ClearCache();
break;
case "clear_all_cache":
ClearAllCache();
break;
default:
Response.StatusCode = 400;
Response.Write("Invalid action");
break;
}
}
void RebuildCache()
{
string key = Request["key"] + "";
// Clear from all tiers
ClearCacheItem(key);
// Rebuild
string html = RebuildArticleCache(key);
int version = DateTime.Now.Ticks.GetHashCode();
// Store in all tiers
StoreInAllTiers(key, html, version);
// Mark front page for rebuild
Global.needRebuildFrontPage = true;
Response.Write("OK");
}
void ClearCache()
{
string key = Request["key"] + "";
ClearCacheItem(key);
Response.Write("OK");
}
void ClearCacheItem(string key)
{
// Remove from memory
Global.memoryCache.TryRemove(key, out _);
// Delete file
string filePath = Server.MapPath($"~/App_Data/cache/{key}.html");
if (File.Exists(filePath))
{
File.Delete(filePath);
}
// Clear from database
DeleteFromDatabaseCache(key);
}
}
App 2 – The Cache Consumer
App 2 maintains its own local cache and validates with App 1:
public partial class Article : System.Web.UI.Page
{
// App 2's local cache
static ConcurrentDictionary<string, CacheItem> localCache = new ConcurrentDictionary<string, CacheItem>();
protected void Page_Load(object sender, EventArgs e)
{
string articleId = Request["id"] + "";
string key = $"article_{articleId}";
// Check local cache first
string html = GetArticleWithCache(key);
Response.Write(html);
}
string GetArticleWithCache(string key)
{
// Check local cache
if (localCache.TryGetValue(key, out CacheItem cached))
{
// Verify version with App 1
int remoteVersion = GetRemoteVersion(key);
if (cached.Version == remoteVersion)
{
// Version matches, use local cache
cached.LastAccess = DateTime.Now;
return cached.Html;
}
}
// Fetch from App 1
string html = FetchFromCacheServer(key);
int version = GetRemoteVersion(key);
// Store in local cache
localCache[key] = new CacheItem
{
Version = version,
Html = html,
LastAccess = DateTime.Now
};
return html;
}
string FetchFromCacheServer(string key)
{
using (var client = new HttpClient())
{
var response = client.GetAsync($"https://api.myapp.com/apiArticle?action=get_cache&key={key}").Result;
return response.Content.ReadAsStringAsync().Result;
}
}
int GetRemoteVersion(string key)
{
using (var client = new HttpClient())
{
var response = client.GetAsync($"https://api.myapp.com/apiArticle?action=get_version&key={key}").Result;
string version = response.Content.ReadAsStringAsync().Result;
return int.Parse(version);
}
}
}
Handling article submission and cache invalidation:
public partial class SubmitArticle : System.Web.UI.Page
{
protected void Page_Load(object sender, EventArgs e)
{
if (Request["action"] == "save")
{
SaveArticle();
}
}
void SaveArticle()
{
string articleId = Request["id"] + "";
string content = Request["content"] + "";
// 1. Save to database
SaveToDatabase(articleId, content);
// 2. Clear local cache
string key = $"article_{articleId}";
localCache.TryRemove(key, out _);
// 3. Tell App 1 to rebuild cache (internal API)
using (var client = new HttpClient())
{
// This goes to localhost on App 1 server
client.GetAsync($"http://localhost:61234/apiMaintenance?action=rebuild&key={key}").Wait();
}
Response.Write("Article saved and cache rebuilt");
}
}
Database Cache Table
Create a simple cache table in your database
CREATE TABLE CacheStorage (
CacheKey VARCHAR(100) PRIMARY KEY,
HtmlContent LONGTEXT,
Version INT,
CreatedDate DATETIME,
LastModified DATETIME
);
This architecture gives you Redis-like distributed caching without Redis. App 1 serves as the cache server, providing centralized cache management for multiple App 2 instances, all using native Vanilla ASP.NET Web Forms with zero external dependencies.
Feature image credit: Photo by Arum Visuals on Unsplash