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 1: Lightning Fast Page Caching Strategy for High Traffic Performance Vanilla ASP.NET Web Forms
Introducing 4 strategies for caching page contents for lighting fast page serving for super high traffic Vanilla ASP.NET Web Forms.
- In-Process Memory Caching
- File based Caching
- IndexedDB (Local Web Browser) Caching
- Multiple Web Instance to Handle Different Tasks
- Database Caching
Basic Introduction
Imagine that we are going to make an article sharing site where users from all over the world can post articles and share it publicly. Then the front page will have various sections to show the latest posted or updated article lists for various categories. Perhaps there are a few sections of latest news, latest advertisements section, perhaps a sliding banner (hero section), latest users activity, top users scoreboard, etc…. it is a summary context for the whole site.
Assume the front page has a site looks something like this:

Here’s the web layout that have the skeleton HTML structure looks like this:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Your Website Title</title>
<!-- some very beautiful css files -->
{{css}}
<!-- some very useful javascript files -->
{{script_top}}
</head>
<body>
<header class="header">
{(header_content}}
</header>
<section class="hero">
<div class="hero-content">
{{hero_content}}
</div>
</section>
<div class="container">
<div class="content-grid">
<main class="main-content">
<div class="content-block">
{{main_content}}
</div>
<div class="content-block">
{{secondary_content}}
</div>
</main>
<aside class="sidebar">
<div class="content-block">
{{sidebar_widget_main}}
</div>
<div class="placeholder-text">
{{sidebar_widget_secondary_1}}
</div>
<div class="placeholder-text">
{{sidebar_widget_secondary_2}}
</div>
</aside>
</div>
<section class="features">
{{feature_cards}}
</section>
</div>
<footer class="footer">
<div class="footer-content">
{{footer_contents}}
</div>
<div class="footer-bottom">
{{footer_bottom}}
</div>
</footer>
<!-- some very useful javascript files -->
{{script_bottom}}
</body>
</html>
Then at the backend of the ASP.NET Web Forms page, you can build the page like this:
// the page caching class
public class PageContent
{
public int Version { get; set; }
public string Html { get; set; }
}
// the code behind for the main page
public partial class Default : System.Web.UI.Page
{
// a thread safe global caching dictionary that can be accessed by any threads/page request
public static ConcurrentDictionary<string, PageContent> dicPageCache = new ConcurrentDictionary<string, PageContent>();
// give a reference name of this page
string main_page_name = "main_page";
protected void Page_Load(object sender, EventArgs e)
{
// go the database and get the cache version of the page
// this serves as the global flag to indicate if there is new content updated
int new_page_version = GetPageVersion("main_page_name");
string html = "";
// now check the global if the cache for the page existed
// or the version of the cache is outdated
if (!dicPageCache.ContainsKey(main_page_name) ||
dicPageCache[main_page_name].Version != new_page_version)
{
// the page is not existed, or the cache is outdated:
// get the basic web skeleton layout, as shown above
// this is the empty skeleton HTML container
string web_layout = GetWebLayout();
// now, go to the database and fetch tremendous amount of data
// to build contents for each sections
string css = GetCss();
string script_top = GetJavascriptBlock("script_top");
string script_bottom = GetJavascriptBlock("script_bottom");
string header_content = GetWebSection("header_content");
string hero_content = GetWebSection("hero_content");
string main_content = GetWebSection("main_content");
string secondary_content = GetWebSection("secondary_content");
string sidebar_widget_main = GetWebSection("sidebar_widget_main");
string sidebar_widget_secondary_1 = GetWebSection("sidebar_widget_secondary_1");
string sidebar_widget_secondary_2 = GetWebSection("sidebar_widget_secondary_2");
string feature_cards = GetWebSection("feature_cards");
string footer_contents = GetWebSection("footer_contents");
string footer_bottom = GetWebSection("footer_bottom");
// combine all the sections of contents to the main skeleton web layout
web_layout = web_layout.Replace("{{css}}", css);
web_layout = web_layout.Replace("{{script_top}}", script_top);
web_layout = web_layout.Replace("{{script_bottom}}", script_bottom);
web_layout = web_layout.Replace("{{header_content}}", header_content);
web_layout = web_layout.Replace("{{hero_content}}", hero_content);
web_layout = web_layout.Replace("{{main_content}}", main_content);
web_layout = web_layout.Replace("{{secondary_content}}", secondary_content);
web_layout = web_layout.Replace("{{sidebar_widget_main}}", sidebar_widget_main);
web_layout = web_layout.Replace("{{sidebar_widget_secondary_1}}", sidebar_widget_secondary_1);
web_layout = web_layout.Replace("{{sidebar_widget_secondary_2}}", sidebar_widget_secondary_2);
web_layout = web_layout.Replace("{{feature_cards}}", feature_cards);
web_layout = web_layout.Replace("{{footer_contents}}", footer_contents);
web_layout = web_layout.Replace("{{footer_bottom}}", footer_bottom);
if (!dicPageCache.ContainsKey(main_page_name))
{
dicPageCache[main_page_name] = new PageContent();
}
dicPageCache[main_page_name].Version = new_page_version;
dicPageCache[main_page_name].Html = web_layout;
}
// get the cache page content from the global dictionary
html = dicPageCache[main_page_name].Html;
// write the HTML to the client
Response.Write(html);
}
}
Another strategy of composing the HTML is by using Task
and StringBuilder
:
// Many fetch data in parallel for performance
var tasks = new Dictionary<string, Task<string>>
{
["css"] = Task.Run(() => GetCss()),
["header_content"] = Task.Run(() => GetWebSection("header_content")),
["main_content"] = Task.Run(() => GetWebSection("main_content")),
["sidebar_widget_main"] = Task.Run(() => GetWebSection("sidebar_widget_main"))
// ... etc
};
// Blocks until all complete
Task.WaitAll(tasks.Values.ToArray());
// Then use StringBuilder for better performance
var sb = new StringBuilder(web_layout);
foreach(var kvp in tasks)
{
sb.Replace($"{{{{{kvp.Key}}}}}", kvp.Value.Result);
}
html = sb.ToString();
Note: Above method uses Task
to access the data from database simultaneously, which… sometimes could worse than synchronous access the data one by one.
Next, how about the frontend? The ASP.NET Web Forms markup?
We don’t need that, because we build everything from the backend, therefore the frontend is just serving as an entry point.
Here’s a typical new blank Web Forms looks like:
<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Default.aspx.cs" Inherits="myweb.Default" %>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
<title></title>
</head>
<body>
<form id="form1" runat="server">
<div>
</div>
</form>
</body>
</html>
Delete all the ASP.NET frontend markup, leave only the first line of page directive declaration:
<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Default.aspx.cs" Inherits="myweb.Default" %>
There you have it, a super efficient lightning fast page serving website. That’s the basic concept.
File Based Caching
In stead of writing the full content page caching in “In-Process Memory
“, such as caching it in a global static ConcurrentDictionary
, you can write the content and saved it as a physical file. Nowadays, web server machines utilize SSD, which can be as fast as just like reading it from RAM. This typical useful serves as the secondary less high traffic than the main front page. You can choose a folder structure like this:
/articles/{article_id}/
All the files and images related to the article can be put into the same folder including the caching page content:
//the main article page cache file
/articles/{article_id}/article_cache_v123.html
//the comment section cache file (First page only)
/articles/{article_id}/comment_cache_v123.html
// other files and images
/articles/{article_id}/upload.pdf
/articles/{article_id}/image1.jpg
/articles/{article_id}/image2.png
/articles/{article_id}/source_code.zip
Therefore, when loading the page, in stead of fetching the database for the articles and comments to build the HTML page, you just read the cache file and fetch it, super lightning fast, no more database fetching.
For the comment sections, you just need to rebuild the html block each time the user submit or update the comments and you only need to build the cache for the first page, as that the most read section, there is a lower percentage of users will read the 2nd page of comment, therefore, from 2nd page onwards, you use the lazy loading method to fetch the 2nd page comment section.
Use IndexedDB Caching
IndexedDB is something like cookies, which stored locally offline on the user web browser. Here’s an introductory JavaScript that utilize IndexedDB
in action to serve as content caching:
// Article Version Check and Cache Management
async function loadArticleWithVersionCheck(articleId) {
try {
// Step 1: Load from IndexedDB first
const cachedData = await getArticleFromDB(articleId);
const localVersion = cachedData ? cachedData.version : 0;
// Show cached content immediately if available
if (cachedData) {
document.getElementById('article-container').innerHTML = cachedData.html;
console.log(`Loaded cached version: ${cachedData.version}`);
}
// Step 2: Check server version via fetch API
const versionResponse = await fetch(`/apiArticle?action=getversion&articleid=${articleId}`);
const serverData = await versionResponse.json();
const serverVersion = serverData.version;
console.log(`Local: ${localVersion}, Server: ${serverVersion}`);
// Step 3: Compare versions
if (localVersion === serverVersion) {
// Versions match - do nothing
console.log('Content is up to date');
return;
}
// Step 4: Versions don't match - show loading and fetch new content
showLoadingAnimation(); // implement your own loading animation
// Fetch new content
const contentResponse = await fetch(`/apiArticle?action=getcontent&articleid=${articleId}`);
const newContent = await contentResponse.text();
// Step 5: Update UI and IndexedDB
document.getElementById('article-container').innerHTML = newContent;
// Update IndexedDB with new version and content
await saveArticleToDB(articleId, {
version: serverVersion,
html: newContent,
timestamp: Date.now()
});
hideLoadingAnimation(); // << implement your own loading animation
console.log(`Updated to version: ${serverVersion}`);
} catch (error) {
console.error('Error checking article version:', error);
// Fallback: show cached content if available
const cachedData = await getArticleFromDB(articleId);
if (cachedData) {
document.getElementById('article-container').innerHTML = cachedData.html;
}
}
}
// Usage example
document.addEventListener('DOMContentLoaded', function() {
const articleId = 12345; // Get from URL or page data
loadArticleWithVersionCheck(articleId);
});
other JavaScript helper functions:
// Helper functions for IndexedDB operations
async function getArticleFromDB(articleId) {
const db = await initDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['articles'], 'readonly');
const store = transaction.objectStore('articles');
const request = store.get(`article_${articleId}`);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function saveArticleToDB(articleId, data) {
const db = await initDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['articles'], 'readwrite');
const store = transaction.objectStore('articles');
const request = store.put({
id: `article_${articleId}`,
...data
});
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// Initialize IndexedDB
async function initDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('ArticleCache', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('articles')) {
db.createObjectStore('articles', { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
and for the backend C#, same create a blank page, which again, looks like this:
<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="apiArticle.aspx.cs" Inherits="myweb.apiArticle" %>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
<title></title>
</head>
<body>
<form id="form1" runat="server">
<div>
</div>
</form>
</body>
</html>
delete everything, leave the first line:
<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="apiArticle.aspx.cs" Inherits="myweb.apiArticle" %>
the backend C# code:
public partial class apiArticle : System.Web.UI.Page
{
protected void Page_Load(object sender, EventArgs e)
{
string action = Request["action"] + "";
action = action.ToLower();
switch (action)
{
case "getcontent":
GetContent();
break;
case "getversion":
GetVersion();
break;
}
}
void GetContent()
{
int articleId = Convert.ToInt32(Request["articleid"] + "");
// Set response type for HTML content
Response.ContentType = "text/html";
// Get article version
int version = GetCurrentVersion(articleId);
int comment_version = GetCommentVersion(articleId);
// First try to get from file cache
string cacheFilePath = $"/articles/{articleId}/article_cache_v{version}.html";
string cacheCommentFilePath = $"/articles/{articleId}/comment_cache_v{version}.html";
string htmlArticlePage = "";
string htmlCommentBlock = "";
if (File.Exists(Server.MapPath(cacheFilePath)))
{
// Serve from file cache - lightning fast!
htmlArticlePage = File.ReadAllText(Server.MapPath(cacheFilePath));
}
else
{
// Fallback: build from database (your original method)
htmlArticlePage = BuildArticleFromDatabase(articleId);
File.WriteAllText(cacheFilePath, htmlArticlePage);
// Clean up old cache files
}
if (File.Exists(Server.MapPath(cacheCommentFilePath))
{
htmlCommentBlock = File.ReadAllText(Server.MapPath(cacheCommentFilePath));
}
else
{
htmlCommentBlock = BuildArticleFromDatabase(articleId);
File.WriteAllText(cacheFilePath, htmlCommentBlock);
// Clean up old cache files
}
htmlArticlePage = htmlArticlePage.Replace("{{comment_block}}", htmlCommentBlock);
Response.Write(htmlArticlePage);
}
void GetVersion()
{
string articleId = Request["articleid"] + "";
// Set response type for JSON
Response.ContentType = "application/json";
// Get current version from database or cache metadata
int currentVersion = GetCurrentVersion(articleId);
// Return simple JSON response
string jsonResponse = $"{{\"version\": {currentVersion}}}";
Response.Write(jsonResponse);
Response.End();
}
// Helper methods - basic concept demonstration
int GetCurrentVersion(string articleId)
{
// Your implementation: get from database, config, or cache metadata
// return DatabaseHelper.GetArticleVersion(articleId);
return 123; // placeholder for demo
}
string BuildArticleFromDatabase(string articleId)
{
// Your implementation: fetch from database and build HTML
// return ArticleBuilder.BuildHtml(articleId);
return "<div>Article content from database...</div>"; // placeholder for demo
}
}
Multiple Web Instance/Application to Handle Different Tasks
For a super busy high traffic website, you make two different ASP.NET Web Forms Websites/Applications.
- App 1 – The Cache Builder
- App 2 – Serve user request, read and deliver the cached content, handle user input and tell App 1 for new content updates
App 1 – The Cache Builder
The first app can be run on a single worker process, as it’s main job is just building the cache contents.
The Front Page Cache Builder
- Use an interval timer to check the update boolean flag for the new content update needs.
- Check the boolean flag every 1 minute or 5 minutes or 10 minutes
- In case there is a new update, build the new full HTML front page and save it to database
The Article Page Cache Builder
- Provide an API Endpoint for the App 2 to access and trigger the page cache building task.
- Save the completed HTML page into database and writes the cache as physical file.
The Comment Section Cache Builder
- Provide an API Endpoint for the App 2 to call for a cache rebuild.
- Save the generated cache into database and physical file.
App 2 – Serving Cache Content, Handle User Inputs
This web application can be served by using multiple worker process (Web Garden), as their primary role is just serving the cache content.
Serving Front Page Content
- First get the cache front page html built by App 1 from database.
- Then, cache it in “In-Process Memory“, the global static
ConcurrentDictionary
, together with the version or any other checksum - For next user request, check the version number only to the database, if both version (In-Process Memory and Database) matched, fetch the HTML cached in
ConcurrentDictionary
directly to the user. - If version number is not matched, fetch it from the database, cache it, and delivery it to user.
Handling Article Updates/Submission
- Perform Insert/Update/Delete for Article updates.
- Then, access the API Endpoint of App 1 to signal for updates, both front page and article page cache rebuild
Handling Comments Section Updates/Submission
- Perform Insert/Update/Delete for comments updates.
- Access the API Endpoint of App 1 to triggering the comment section HTML rebuild, first page only
The Cache Hierarchy Overview
Browser Cache (IndexedDB) <- 0ms (instant)
↓ (miss)
CDN Edge Cache <- 10-50ms
(3rd party service geographical content delivery network)
↓ (miss)
Application Memory Cache <- 0.1-1ms
(ConcurrentDictionary)
↓ (miss)
File System Cache <- 5-10ms
↓ (miss)
Database Result Cache <- 10-50ms
↓ (miss)
--------------------------------------------------
(anything till this point is still lightning fast)
--------------------------------------------------
↓
Full Database Query <- 100-5000ms (rebuild everything)
(the most cpu intensive and time consuming)
↓
Composing/Assembly of Final HTML
There are lots of methods of how to compose the final HTML content. But nonetheless, the caching strategy is what make the most impactful to the website’s performance and content serving efficiency, regardless of what the method of HTML compose is using, for example: using template-model, stringbuilder
, etc… For a busy website, the caching strategy become more profound than HTML template strategy. Around 99% rate of request of page load actually hitting cache than re-composing the HTML load.
Take one of this video from my youtube channel (shown below). 31K views vs 379 (1.2%) likes vs 34 (0.1%) comments. (I’m just here to show the statistic only) I’m not seeking nor implying for likes, subscribes or comment 😀. Focus on the statistic, that sounds just right, right? 99% of the time is just hitting the cache content, only 1% of time actually perform an “Insert/Update/Delete”.
The proves that caching strategy is far more profound than strategy of composing a page’s HTML.

Above summarize some of the idea that eliminates a lot of round trips to database, and makes your website highly efficient, performing in hyper lightning speed. The CPU workload will be extremely light.
Thanks for reading and Happy coding.
Feature image credit: Photo by Felix Mittermeier on Unsplash