Part 2: Building Redis-Like Distributed Cache with Vanilla ASP.NET Web Forms

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