Complete Architecture Reference for Pageless ASP.NET Web Forms in MD (Markdown) Format
This is a complete architecture reference, guideline and code convention for building modern web applications on Pageless ASP.NET Web Forms without Master Page, ASPX page files, Server Controls, ViewState, or PostBack. It covers C# string-based HTML template rendering, API endpoint patterns, Fetch API integration, file uploads, Server-Sent Events, WebSocket, and background task management.
Updated on August 10, 2026.
Complete Architecture Reference for Pageless ASP.NET Web Forms in MD (Markdown) Format
Overview
This project uses a Pageless Architecture — rendering full HTML pages entirely in C# using StringBuilder and Response.Write, intercepted at the Global.asax.cs pipeline. No .aspx markup files, no master pages, no ViewState, no server controls, no page lifecycle.
Every HTTP request flows through Global.asax.cs, where a switch statement routes the path to a static handler method. The handler builds the complete HTML document — from <!DOCTYPE html> to </html> — as a C# string and writes it directly to the response stream.
That string is always composed as a multiline interpolated verbatim string ($@"…") with single-quoted attributes — never as a chain of one-line sb.Append calls. See HTML String Composition — The Multiline Interpolation Rule below; every code example in this document follows it.
HTTP Errors Pass-Through
Pageless Architecture handles all requests through Global.asax.cs rather than physical .aspx files. By default, IIS intercepts responses with error status codes (404, 403, 500, etc.) and replaces them with its own error pages — overriding whatever your handler produced.
To prevent this, add the following to web.config:
<system.webServer>
<httpErrors existingResponse="PassThrough" />
</system.webServer>
This tells IIS to leave the application's response untouched, allowing code behind handler to remain the single source of truth for all output — including error pages.
Core Principle: NO Traditional WebForms Patterns
| ❌ AVOID | ✅ USE INSTEAD |
|---|---|
.aspx markup files | C# string-based HTML rendering |
Master pages (.master) | PageTemplate class (C#) |
<asp:Button>, <asp:TextBox> | Plain HTML: <button>, <input> |
OnClick="btnSave_Click" | onclick="saveItem()" (JS function) |
| ViewState | Client-side state, re-fetch from API |
Postback / IsPostBack | Fetch API calls |
UpdatePanel / AJAX Toolkit | Native fetch() |
| Code-behind event handlers | API endpoint actions |
Page lifecycle (Page_Load, etc.) | Pipeline interception at Global.asax.cs |
⚠️ CRITICAL: Button Type Declaration
<!-- Triggers postback (default type="submit") -->
<button onclick="saveItem()">Save</button>
<!-- Executes JavaScript only, no postback -->
<button type="button" onclick="saveItem()">Save</button>
Default JSON Library
Newtonsoft.JSON
using Newtonsoft.Json;
Response.ContentType = "application/json";
Response.Write(JsonConvert.SerializeObject(obj));
JSON naming convention: direct matching of C# class fields or properties. Use default standard. It can be PascalCase (PropertyName).
If the fields are primarily matching MySQL columns, use snake_case (property_name).
Never use CamelCase (propertyName).
Data Model Class Convention
Database model classes are prefixed with ob (object). The preferred pattern uses private fields in snake_case (matching MySQL column names) with public properties in PascalCase (matching C# conventions):
public class obBook
{
int id = 0;
string title = "";
string author = "";
int year = 0;
DateTime date_created = DateTime.MinValue;
DateTime date_modified = DateTime.MinValue;
public int Id { get { return id; } set { id = value; } }
public string Title { get { return title; } set { title = value; } }
public string Author { get { return author; } set { author = value; } }
public int Year { get { return year; } set { year = value; } }
public DateTime DateCreated { get { return date_created; } set { date_created = value; } }
public DateTime DateModified { get { return date_modified; } set { date_modified = value; } }
}
MySqlExpress maps MySQL columns to the private fields by matching snake_case names — no attribute mapping or naming configuration needed. C# code accesses the data through PascalCase public properties. Both layers work automatically with the same class.
Pipeline Interception — Where Pageless Rendering Begins
In Pageless Architecture, every request is intercepted in Application_BeginRequest and routed by a single switch statement. The built-in ASP.NET session module is bypassed entirely — session state is provided by a custom in-process store (see Custom Session State below) which is available immediately at BeginRequest, so there is no need to wait for AcquireRequestState or PostAcquireRequestState.
| Entry Point | Role |
|---|---|
Application_Start | One-time init: connection string, DB migration, start SessionSweeper background task |
Application_BeginRequest | The single routing point. Custom session is restored here via AppSession.TryRestoreFromCookie() before the route switch dispatches to a handler |
Routing — Global.asax.cs
All routes are defined as a switch statement. This is the routing table for the entire application:
public class Global : System.Web.HttpApplication
{
protected void Application_BeginRequest(object sender, EventArgs e)
{
string path = Request.Path.ToLower().Trim().TrimEnd('/');
switch (path)
{
case "/":
case "/home":
RH.HomePage.HandleRequest();
return;
case "/api-health":
RH.HealthApi.HandleRequest();
return;
case "/about":
RH.AboutPage.HandleRequest();
return;
case "/books":
RH.BookPage.HandleRequest();
return;
case "/bookapi":
RH.BookPageApi.HandleRequest();
return;
}
}
}
Two routes per feature — one page, one API. Add more case entries as you add features. No route configuration files, no attribute routing — you look at the switch statement and see every URL the application responds to.
Custom Session State
The built-in ASP.NET SessionStateModule is disabled. All session state is held in a single application-wide public static ConcurrentDictionary keyed by a random session id stored in an ssid cookie. This makes session data available the instant a request enters Application_BeginRequest. IIS/ASP.NET built-in Session State infrastructure is not reliable in pageless mode, do not wait for AcquireRequestState, no EnableSessionState="true" on handlers, no IRequiresSessionState marker interface.
Why custom
| Built-in ASP.NET Session | Custom SessionStore |
|---|---|
Locked behind AcquireRequestState — forces routing into PostAcquireRequestState | Available at BeginRequest — single routing point |
| Per-request reader/writer lock serializes async handlers | Lock-free ConcurrentDictionary |
| Hard to inspect, debug, or sweep | Plain dictionary — enumerable, sweepable, easy to log |
| Tied to InProc / StateServer / SQL provider | Trivially swappable for any backing store |
Three-layer model
Session lives in three places, in order of speed:
cookies (ssid + lsid) ──► ConcurrentDictionary (RAM) ──► login_sessions table (DB)
▲ ▲ │
└──────────────────────────────┴──────────────────────────────┘
rehydrate on cookie hit
ssidcookie — a random 48-char id, the lookup key into the in-memory dictionary. Lifespan: in-memory only (lost on app-pool recycle).SessionStore.Sessions—public static ConcurrentDictionary<string, StateObject>. EachStateObjectwraps anotherConcurrentDictionary<string, object>for arbitrary per-user data plus aLastAccessUtctimestamp.login_sessionsDB table — persistent "Remember Me" record holding(user_id, token, date_expiry, cookie_persistent). Referenced by anlsidcookie. Survives app restarts.
Activation — first request
// SessionStore.Current — called transparently on first read/write
public static StateObject Current
{
get
{
HttpContext ctx = HttpContext.Current;
string sid = ctx.Request.Cookies[CookieName]?.Value;
StateObject state;
if (string.IsNullOrEmpty(sid) || !Sessions.TryGetValue(sid, out state))
{
sid = NewId(); // 48-char random hex
state = new StateObject();
Sessions[sid] = state; // <-- ConcurrentDictionary
ctx.Response.Cookies.Set(new HttpCookie(CookieName, sid)
{
HttpOnly = true,
Secure = ctx.Request.IsSecureConnection,
SameSite = SameSiteMode.Lax
});
}
state.LastAccessUtc = DateTime.UtcNow;
return state;
}
}
Resuming session — DB → ConcurrentDictionary → cookie
When the in-memory entry is gone (app-pool recycle, idle sweep, cold machine) but the user still holds a valid lsid cookie, the next request walks back up the chain:
// Called once per request from Application_BeginRequest
public static void TryRestoreFromCookie()
{
if (IsLoggedIn) return; // already in ConcurrentDictionary
obUser user = UserSession.TryRestoreFromCookie(); // ── reads "lsid" cookie
// ── SELECT … FROM login_sessions
// JOIN users WHERE token=@t AND not expired
if (user != null)
LoginUser = user; // ── writes user back into StateObject
// (lives in the ConcurrentDictionary)
}
Flow: lsid cookie → DB lookup → obUser materialized → stored into the per-session StateObject inside the ConcurrentDictionary → subsequent requests in the same app-pool lifetime hit RAM directly. The cookie's expiry is rolled forward only when remaining lifetime drops below 1/12 of the original window, keeping DB writes infrequent.
Accessing session from handlers
AppSession is a thin static facade — handlers never touch HttpContext.Session:
public static class AppSession
{
public static obUser LoginUser
{
get { return SessionStore.Current?[AppSessionKeys.LoginUser] as obUser; }
set { var s = SessionStore.Current; if (s != null) s[AppSessionKeys.LoginUser] = value; }
}
public static bool IsLoggedIn => LoginUser != null;
}
Inside any page or API handler:
if (!AppSession.IsLoggedIn) { Response.Redirect("/login"); return; }
obUser me = AppSession.LoginUser;
Lifecycle
- Logout —
SessionStore.Abandon()removes the entry from the dictionary and expires thessidcookie;UserSession.DeletePersistentSession()deletes thelogin_sessionsrow and expires thelsidcookie. - Idle cleanup —
SessionSweeperruns hourly viaHostingEnvironment.QueueBackgroundWorkItem, droppingStateObjectentries idle for more than two hours and deleting expiredlogin_sessionsrows. Sweeping the dictionary is just aforeachoverKeyValuePairs — no special API needed. - App-pool recycle — the dictionary is gone, but any user with a live
lsidcookie is transparently restored on their next request.
Web.config — disable built-in session module
<system.web>
<sessionState mode="Off" />
</system.web>
This removes the per-request AcquireRequestState lock entirely, which is what makes single-point routing in BeginRequest clean.
The Two-Handler Pattern
Every feature follows this pattern:
| Handler | Purpose | Returns |
|---|---|---|
Page Handler (BookPage) | Renders the full HTML page | text/html — complete document |
API Handler (BookApi) | Processes Fetch API calls | application/json or text/html fragment |
One page, one API. That's the entire architecture for any feature.
⚠️ One Handler Per File Rule (Default Mode)
- Default Standard: Maintain 1 request handler per C# (
.cs) file (e.g.,BookPage.csfor page rendering,BookPageApi.csfor API handling). - Avoid Multi-Handler Files: Unless there is a specific, compelling architectural reason, avoid bundling multiple page request handlers or API handlers into a single
.csfile. - Prevent Bloating: Keeping handlers in separate files avoids single
.csfile bloat, ensuring clean structure, easy maintenance, and simple file navigation.
File Structure Pattern
Since there are no .aspx files, all code lives in .cs class files:
/Global.asax.cs ← routing table
/engine/
config.cs ← connection string, app settings
ApiHelper.cs ← shared response helpers
PageTemplate.cs ← shared HTML template (replaces master page)
/engine/
/engine/ob
/engine/Models
obBook.cs ← data model
/engine/RH/
HomePage.cs ← page handler
AboutPage.cs ← page handler
BookPage.cs ← page handler
BookPageApi.cs ← API handler
/css/
site.css ← app-wide styles, loaded on every page
books.css ← page-specific, loaded by BookPage only
/components/
book-picker.css ← ships with book-picker.js
/js/
site.js ← app-wide behavior, loaded on every page
books.js ← page-specific, loaded by BookPage only
/components/
book-picker.js ← portable widget, any page
See JavaScript Organization — The Four Rules and CSS Organization — Same Scope Rules for what decides which file a given script or rule belongs in.
ApiHelper — Shared Response Utilities
Every handler uses ApiHelper for response writing and termination:
using Newtonsoft.Json;
using System;
using System.Web;
namespace System
{
public static class ApiHelper
{
static HttpRequest Request
{
get
{
if (HttpContext.Current == null)
throw new InvalidOperationException("ApiHelper called outside of an HTTP request context.");
return HttpContext.Current.Request;
}
}
static HttpResponse Response
{
get
{
if (HttpContext.Current == null)
throw new InvalidOperationException("ApiHelper called outside of an HTTP request context.");
return HttpContext.Current.Response;
}
}
public static string GetBaseUrl()
{
Uri url = Request.Url;
return $"{url.Scheme}://{url.Host}{(url.IsDefaultPort ? "" : ":" + url.Port)}";
}
public static void EndResponse()
{
// So IIS will skip handling custom errors
Response.TrySkipIisCustomErrors = true;
try
{
Response.Flush();
}
catch { /* client already disconnected — ignore */ }
Response.SuppressContent = true;
// The most reliable way in WebForms / IIS-integrated pipeline
HttpContext.Current.ApplicationInstance.CompleteRequest();
}
public static void WriteJson(object obj)
{
Response.ContentType = "application/json";
Response.Write(JsonConvert.SerializeObject(obj));
}
public static void WriteSuccess(string message = "Success")
{
WriteJson(new { success = true, message });
}
public static void WriteError(string message, int statusCode = 400)
{
Response.StatusCode = statusCode;
WriteJson(new { success = false, message });
}
}
}
EndResponse() is the Pageless equivalent of Response.End() — but without the ThreadAbortException. It flushes buffered content, prevents additional output, and tells ASP.NET to skip to EndRequest cleanup.
HTML String Composition — The Multiline Interpolation Rule
Rule: compose HTML as one multiline interpolated verbatim string ($@"…"), not as a sequence of sb.Append calls. Quote every HTML attribute with single quotes.
This is the house style for every handler, every fragment, and every PageTemplate method in this reference. It is not a formatting preference — the append-per-line alternative costs correctness, not just readability.
// ❌ Anti-pattern — one Append per line, concatenated values, escaped quotes
StringBuilder sb = new StringBuilder();
sb.Append("<div class=\"card-book\">");
sb.Append("<strong>" + b.Title + "</strong><br>");
sb.Append("Author: " + b.Author + "<br>");
sb.Append("<button onclick=\"editBook(" + b.Id + ")\">Edit</button>");
sb.Append("</div>");
// ✅ The rule — one block, HTML shape visible, every hole encoded
sb.Append($@"
<div class='card-book'>
<strong>{HttpUtility.HtmlEncode(b.Title)}</strong><br>
Author: {HttpUtility.HtmlEncode(b.Author)}<br>
<button type='button' onclick='editBook({b.Id})'>Edit</button>
</div>");
Why
| Reason | What the anti-pattern costs |
|---|---|
| Structure is visible | Indentation in the C# source is the indentation of the emitted HTML. Unclosed tags are visible on sight; in an Append stack they are not |
| Escaping tax | class=\"card\" in a regular string, or class=""card"" in a verbatim one — both mangle the markup. Single-quoted attributes inside $@"…" need no escaping at all |
| Encoding discipline | {HttpUtility.HtmlEncode(x)} sits inside the tag it protects, so an unencoded hole is obvious in review. + x + concatenation hides it |
| Diff noise | Adding one attribute touches one line, not a re-flowed chain of Append calls |
| Fewer calls | One Append per fragment instead of one per line — less StringBuilder churn on hot list-rendering loops |
The three approved forms
1. $@"…" — multiline verbatim + interpolated. The default. Use for any block containing at least one dynamic value.
sb.Append($@"
<meta property='og:title' content='{encodedTitle}'>
<meta property='og:url' content='{OgUrl}'>");
2. @"…" — multiline verbatim, no $. Use when the block is entirely static. Omitting $ means { and } need no doubling — which matters when the block carries inline CSS or a JS object literal.
sb.Append(@" <main class='site-main'>
");
3. $"…" — single line. The only exception: a single tag emitted conditionally, where a multiline block would be noise.
if (isLoggedIn)
{
sb.Append($" <a href='/u/{HttpUtility.HtmlAttributeEncode(username)}'>Profile</a>\n");
sb.Append(" <a href='/logout'>Logout</a>\n");
}
Constraints and details
- Interpolation holes cannot span multiple lines on C# 7.3. A hole must be a single-line expression. Compute anything longer — a ternary, a formatted date, a conditional class name — into a local before the block, as
PageTemplatedoes withencodedTitleandencodedDesc. - Write
$@", not@$". The reversed token order is only legal from C# 8.0 onward. - Single quotes are the reason this works. HTML5 treats
class='x'andclass="x"identically, but"inside a verbatim string must be doubled to"". Single-quoted attributes keep the markup readable and keep the encoder — not the quoting — the thing you have to get right. - The leading newline after
$@"is intentional. It lets the first tag start at column 0 of the next source line so the block's indentation reads as HTML indentation. The extra whitespace is insignificant in the rendered output. - Encoding is not optional.
HttpUtility.HtmlEncodefor element content,HttpUtility.HtmlAttributeEncodefor a hole inside an attribute value,JsonConvert.SerializeObjectfor a hole inside<script>. See ⚠️ CRITICAL: Data Crosses the Boundary Only as JSON below — the same rule stated from the JavaScript side. - Never interpolate raw HTML from user input. A hole is a value, never markup. Composing a child fragment is done by appending a helper's return value, not by embedding pre-built HTML from a request.
PageTemplate — Replaces the Master Page
The PageTemplate class generates the shared HTML shell — <head>, navigation, footer, scripts — that wraps every page. It serves the same role as a .master file, but as a plain C# class.
using System;
using System.Collections.Generic;
using System.Text;
using System.Web;
namespace System.engine
{
public class PageTemplate
{
// --- Page Meta / SEO ---
string _title = "";
public string Title
{
get
{
if (!_title.Contains("My Website"))
{
return _title + " - My Website";
}
return _title;
}
set
{
_title = value;
}
}
public string Description = "Welcome to My Website.";
public string FaviconIco = "/favicon.ico";
public string Favicon32 = "/media/favicon-32x32.png";
public string Favicon16 = "/media/favicon-16x16.png";
public string AppleTouchIcon = "/media/favicon-180x180.png";
public string Manifest = "/media/site.webmanifest";
public string MsAppTileColor = "#E0F3EF";
public string MsAppTileImage = "/media/favicon-150x150.png";
public string ThemeColor = "#E0F3EF";
public string OgType = "website";
public string OgUrl = "https://mywebsite.com";
public string OgImage = "https://mywebsite.com/media/og-image.png";
public int OgImageWidth = 1200;
public int OgImageHeight = 630;
public string TwitterCard = "summary_large_image";
// --- Extra Raw HTML ---
public string ExtraHeaderText = "";
public string ExtraFooterText = "";
// ==============================
// GenerateHtmlHeader
// ==============================
public string GenerateHtmlHeader()
{
string encodedTitle = HttpUtility.HtmlEncode(Title);
string encodedDesc = HttpUtility.HtmlEncode(Description);
StringBuilder sb = new StringBuilder();
sb.Append($@"<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<title>{encodedTitle}</title>
<meta name='description' content='{encodedDesc}'>
<!-- Favicon -->
<link rel='icon' href='{FaviconIco}' sizes='48x48'>
<link rel='icon' type='image/png' sizes='32x32' href='{Favicon32}'>
<link rel='icon' type='image/png' sizes='16x16' href='{Favicon16}'>
<link rel='apple-touch-icon' sizes='180x180' href='{AppleTouchIcon}'>
<link rel='manifest' href='{Manifest}'>
<meta name='msapplication-TileColor' content='{MsAppTileColor}'>
<meta name='msapplication-TileImage' content='{MsAppTileImage}'>
<meta name='theme-color' content='{ThemeColor}'>
<!-- Open Graph / Facebook -->
<meta property='og:type' content='{OgType}'>
<meta property='og:url' content='{OgUrl}'>
<meta property='og:title' content='{encodedTitle}'>
<meta property='og:description' content='{encodedDesc}'>
<meta property='og:image' content='{OgImage}'>
<meta property='og:image:width' content='{OgImageWidth}'>
<meta property='og:image:height' content='{OgImageHeight}'>
<!-- Twitter -->
<meta name='twitter:card' content='{TwitterCard}'>
<meta name='twitter:url' content='{OgUrl}'>
<meta name='twitter:title' content='{encodedTitle}'>
<meta name='twitter:description' content='{encodedDesc}'>
<meta name='twitter:image' content='{OgImage}'>
<link rel='stylesheet' href='/css/site.css' />
");
// Extra header text (raw HTML)
if (ExtraHeaderText.Length > 0)
{
sb.AppendLine(ExtraHeaderText);
}
sb.Append(@"</head>
<body>
");
// --- Navigation Bar ---
sb.Append(RenderNavbar());
// --- Open Main Content Container ---
sb.Append(@" <main class='site-main'>
");
return sb.ToString();
}
// ==============================
// GenerateHtmlFooter
// ==============================
public string GenerateHtmlFooter()
{
StringBuilder sb = new StringBuilder();
sb.Append($@"
</main>
<footer class='site-footer'>
<div class='footer-inner'>
<p>© {DateTime.Now.Year} My Website</p>
<p>
<a href='/about'>About</a> -
<a href='/contact'>Contact</a>
</p>
</div>
</footer>
<script src='/js/site.js'></script>
");
// Extra footer text (raw HTML)
if (ExtraFooterText.Length > 0)
{
sb.AppendLine(ExtraFooterText);
}
sb.Append(@"
</body>
</html>");
return sb.ToString();
}
// ==============================
// Navigation Bar
// ==============================
string RenderNavbar()
{
StringBuilder sb = new StringBuilder();
sb.Append(@" <header class='site-header'>
<div class='header-inner'>
<a href='/' class='site-logo'>
<img src='/media/logo-40x40.png' />
</a>
<a href='/' class='site-logo'>My Website</a>
<button class='nav-toggle' onclick='toggleNav()' aria-label='Menu'>
<span></span><span></span><span></span>
</button>
<nav class='site-nav' id='siteNav'>
<a href='/'>Home</a>
<a href='/about'>About</a>
<a href='/contact'>Contact</a>
");
// Session-aware content — read from custom AppSession (not HttpContext.Session)
if (AppSession.IsLoggedIn)
{
string username = AppSession.LoginUser.Username;
sb.Append($" <a href='/u/{HttpUtility.HtmlAttributeEncode(username)}'>Profile</a>\n");
sb.Append(" <a href='/logout'>Logout</a>\n");
}
else
{
sb.Append(" <a href='/login'>Login</a>\n");
sb.Append(" <a href='/register'>Register</a>\n");
}
sb.Append(@" </nav>
</div>
</header>
<div class='nav-overlay' id='navOverlay' onclick='toggleNav()'></div>
");
return sb.ToString();
}
}
}
Using PageTemplate in a Page Handler
Every page handler follows the same pattern — configure metadata, render begin, append page content, render end:
public class HomePage
{
public static void HandleRequest()
{
HttpResponse Response = HttpContext.Current.Response;
StringBuilder sb = new StringBuilder();
PageTemplate pt = new PageTemplate()
{
Title = "Home",
Description = "Welcome to our website."
};
// Shared header + navbar + container open
sb.Append(pt.GenerateHtmlHeader());
// --- Page-specific content ---
sb.Append("<h1>Welcome</h1>");
sb.Append("<p>Browse our latest content below.</p>");
// --- End page-specific content ---
// Shared footer + scripts + close
sb.Append(pt.GenerateHtmlFooter());
Response.ContentType = "text/html; charset=utf-8";
Response.Write(sb.ToString());
ApiHelper.EndResponse();
}
}
Adding Page-Specific CSS and JavaScript
Use lstTopCss, lstTopScript, and lstBottomScript to inject page-specific resources:
PageTemplate pt = new PageTemplate()
{
Title = "Book Catalog",
Description = "Browse our collection of books."
};
string extraHeaderText = $@"
{StaticAsset.Css("/css/books.css")}
{StaticAsset.Script("/js/books.js")}
";
pt.ExtraHeaderText = extraHeaderText;
sb.Append(pt.GenerateHtmlHeader());
// ... page content ...
sb.Append("....");
sb.Append(pt.GenerateHtmlFooter());
StaticAsset versions the URL from the file's last write time so browsers never cache a stale script — see JavaScript Organization — The Four Rules and CSS Organization — Same Scope Rules below, which define what belongs in /js/*.js and /css/*.css versus in the C# string, and which of those files a given page should load.
Equivalence to Master Page
| Master Page Concept | Pageless Equivalent |
|---|---|
.master file | PageTemplate class |
<head> section | GenerateHtmlHeader() |
| Navigation bar | RenderNavbar() |
<asp:ContentPlaceHolder> | Gap between GenerateHtmlHeader() and GenerateHtmlFooter() |
| Footer + closing tags | GenerateHtmlFooter() |
ContentPlaceHolder for head | lstTopCss, lstTopScript, ExtraHeaderText |
JavaScript Organization — The Four Rules
C# renders the HTML. It does not host the JavaScript. Four rules decide where every line of script lives — Rules 1 and 2 answer "inline or file?", Rules 3 and 4 answer "which file?".
| # | Rule | Answers |
|---|---|---|
| 1 | Dynamic values — ids, JSON data, localized strings, per-request state — are emitted by C# as a small inline data island | What may stay inline |
| 2 | Static behavior — functions, event wiring, rendering logic — is extracted to an external /js/*.js file | What must be a file |
| 3 | Scope decides the file — app-wide behavior goes in site.js (loaded on every page); one-page behavior gets its own dedicated file | Which file |
| 4 | Reusable widget — a portable component file carrying its own markup and behavior, initialized in one line | Which file |
The whole strategy in one sentence: C# emits data; files hold behavior; scope picks the file.
❌ The Anti-Pattern This Section Exists to Prevent
Embedding hundreds of lines of static JavaScript inside C# string literals. It compiles, it runs, and it is still wrong:
| Cost | Why it matters |
|---|---|
| No tooling | Inside a C# string there is no JS syntax highlighting, no linting, no "go to definition". A misspelled function name ships silently and fails at runtime in the browser |
| Escaping tax | "" doubling in verbatim strings and {{ }} doubling in interpolated strings mangle the JS; code gets rewritten to dodge quotes rather than to be clear |
| No browser caching | Inline script is re-downloaded with every page view. An external file downloads once and is cached |
| Recompile to change JS | A one-character JS fix requires rebuilding and redeploying the DLL |
| Blocks CSP hardening | A future Content-Security-Policy that forbids inline script is impossible while behavior is inline |
Small page glue (a handful of onclick wirings, an init call) may stay inline. The threshold: inline script that exceeds roughly one screenful, or that defines reusable functions rather than wiring, belongs in a file.
Rule 1 — Dynamic Values as a Data Island
Values only C# knows are declared as JavaScript globals in a small inline block, before the external file that consumes them:
// Page handler — prepare the values first (interpolation holes cannot span
// multiple lines on C# 7.3, and it reads better anyway) ...
var lbl = new
{
confirmDelete = isEnglish ? "Delete this book?" : "确定删除此书?",
saved = isEnglish ? "Saved" : "已保存"
};
// ... then emit data, then the behavior that reads it
sb.Append($@"
<script>
// dynamic values — the only inline JavaScript on this page
const member_id = {member.Id};
const books = {JsonConvert.SerializeObject(lstBook)};
const LBL = {JsonConvert.SerializeObject(lbl)};
</script>
{StaticAsset.Script("/js/books.js")}
");
// /js/books.js — static behavior; reads the globals declared above it
document.addEventListener('DOMContentLoaded', function () {
renderBooks(books); // <- data island
document.getElementById('btn-save')
.addEventListener('click', saveBook);
});
async function saveBook() {
var formData = new FormData();
formData.append('action', 'save-book');
formData.append('member_id', member_id); // <- data island
/* ... */
}
The data island is declarations only — see ⚠️ CRITICAL: Execution Timing below. Keep it small: it is the per-request payload, not a place for logic. If a <script> block emitted by C# contains a function keyword, Rule 2 has been violated.
Rule 2 — Static Behavior in an External File, with Auto Cache-Buster
There is no bundler, so cache-busting is done by versioning the URL with the file's last write time. The browser re-downloads exactly when the file actually changed — no manual version numbers, no stale scripts on client phones after a redeploy:
using System.IO;
using System.Web.Hosting;
namespace System.engine
{
public static class StaticAsset
{
// "/js/books.js" -> "<script src='/js/books.js?v=20260805103042'></script>"
public static string Script(string virtualPath)
=> $"<script src='{virtualPath}?v={Version(virtualPath)}'></script>";
public static string Css(string virtualPath)
=> $"<link rel='stylesheet' href='{virtualPath}?v={Version(virtualPath)}' />";
// Rule 4 — one line pulls in a component's stylesheet + script together
public static string Component(string name)
=> Css($"/css/components/{name}.css") + "\n" + Script($"/js/components/{name}.js");
static string Version(string virtualPath)
{
string physical = HostingEnvironment.MapPath("~" + virtualPath);
if (physical == null || !File.Exists(physical)) return "0";
return File.GetLastWriteTimeUtc(physical).ToString("yyyyMMddHHmmss");
}
}
}
Reading the write time per request is a metadata lookup the OS caches — the cost is negligible. Do not memoize it into a static dictionary: editing a .js file does not recycle the app pool, so a memoized version would serve stale URLs during development.
Use StaticAsset for the shared assets too — PageTemplate should emit {StaticAsset.Css("/css/site.css")} and {StaticAsset.Script("/js/site.js")} rather than hard-coded paths, or the two files most likely to change go out unversioned.
Rule 3 — Scope Decides the File
Once behavior is in a file, one question picks which file: would a second page use this unchanged?
| Scope | Home | Loaded by |
|---|---|---|
App-wide — nav toggle, toast/alert, fetch wrapper, escapeHtml, date/number formatting, anything the template's own markup calls | /js/site.js | GenerateHtmlFooter() — every page |
| Page-specific — this page's list rendering, its form validation, its API calls | /js/<page>.js, one dedicated file per page | That page's handler, via ExtraHeaderText |
| Reusable widget — markup + behavior used on several pages | /js/components/<name>.js (Rule 4) | Only the pages that mount it |
/js/
site.js ← app-wide, loaded on every page
books.js ← BookPage only
member-profile.js ← MemberProfilePage only
/components/
book-picker.js ← portable, any page
image-uploader.js
- One page, one file, named after the page handler.
BookPage.cs→/js/books.js. Never a sharedmisc.js/common2.jsdumping ground — that issite.jsbloat wearing a different filename. - Page-specific code must never land in
site.js. Every visitor downloads and parsessite.json every page; it is the one file whose cost is paid app-wide, so what lives there has to earn app-wide value. A file that only one page uses costs every other page for nothing, andsite.jsonly ever grows — nobody dares delete from it later. - A page file may assume its page. It can reference that page's DOM ids and its data-island globals.
site.jsmay assume neither — it runs on pages that have never heard of them, so anything it touches must be guarded (var el = document.getElementById('siteNav'); if (!el) return;). - Promotion path. The moment a second page needs a function verbatim, promote it — to
site.jsif it is small and universal, to a component (Rule 4) if it owns markup. Copy-pasting a function between two page files is the signal, not the solution. - Name collisions. Page files declare into the global scope, but only one page file loads per page, so plain function names are fine — provided they do not shadow a
site.jsname.
Rule 4 — Reusable Widgets as Portable Components
A component owns its markup and its behavior in one file, so using it anywhere is a single line. The page emits an empty mount element; the component fills it.
// Page handler — one line to load it, one element to mount it
pt.ExtraHeaderText = $@"
{StaticAsset.Component("book-picker")}
";
sb.Append($@"
<div id='book-picker'></div>
<script>
const books = {JsonConvert.SerializeObject(lstBook)};
const LBL = {JsonConvert.SerializeObject(lbl)};
</script>
{StaticAsset.Script("/js/books.js")}
");
// /js/books.js — one line to use it
document.addEventListener('DOMContentLoaded', function () {
var picker = new BookPicker({
mount: '#book-picker',
books: books, // <- data island, passed IN
labels: LBL,
onPick: function (id) { loadBook(id); }
});
});
// /js/components/book-picker.js — self-contained: markup + behavior + public API
(function (window) {
'use strict';
function BookPicker(options) {
options = options || {};
this.mount = typeof options.mount === 'string'
? document.querySelector(options.mount)
: options.mount;
this.books = options.books || [];
this.onPick = options.onPick || function () {};
this.labels = Object.assign(
{ search: 'Search', empty: 'No books found' }, // works with no labels passed
options.labels || {});
this._render();
this._wire();
}
// --- internal: renders its own HTML ---
BookPicker.prototype._render = function () {
this.mount.classList.add('bookpicker');
this.mount.innerHTML = `
<input class="bookpicker-search" type="text" placeholder="${esc(this.labels.search)}">
<ul class="bookpicker-list"></ul>`;
// queries scoped to this.mount — never document.getElementById on a fixed id,
// or two instances on one page fight over the same element
this._input = this.mount.querySelector('.bookpicker-search');
this._list = this.mount.querySelector('.bookpicker-list');
this._paint(this.books);
};
BookPicker.prototype._paint = function (books) {
if (!books.length) {
this._list.innerHTML = `<li class="bookpicker-empty">${esc(this.labels.empty)}</li>`;
return;
}
this._list.innerHTML = books.map(function (b) {
return `<li class="bookpicker-item" data-id="${b.Id}">${esc(b.Title)}</li>`;
}).join('');
};
BookPicker.prototype._wire = function () {
var self = this;
this._onInput = function () {
var q = self._input.value.toLowerCase();
self._paint(self.books.filter(function (b) {
return b.Title.toLowerCase().indexOf(q) >= 0;
}));
};
this._onClick = function (e) { // delegation — survives repaint
var li = e.target.closest('.bookpicker-item');
if (li) self.onPick(parseInt(li.dataset.id, 10));
};
this._input.addEventListener('input', this._onInput);
this._list.addEventListener('click', this._onClick);
};
// --- public API ---
BookPicker.prototype.setBooks = function (books) {
this.books = books;
this._paint(books);
};
BookPicker.prototype.destroy = function () {
this._input.removeEventListener('input', this._onInput);
this._list.removeEventListener('click', this._onClick);
this.mount.innerHTML = '';
this.mount.classList.remove('bookpicker');
};
function esc(t) {
var d = document.createElement('div');
d.textContent = t == null ? '' : t;
return d.innerHTML;
}
window.BookPicker = BookPicker; // the single global this file adds
})(window);
The contract that makes a component portable — break any line of it and it stops being drop-in:
| Requirement | Why |
|---|---|
One file, one global — an IIFE exposing exactly one constructor on window | Nothing else leaks; two components can never collide |
Everything arrives through options | A component that reads a data-island global (books, member_id) is welded to one page. Passing data in is the whole difference between reusable and not |
Renders its own markup into mount | The page contributes one empty <div>. If the page has to emit the widget's inner HTML, the widget is not portable — it is a page fragment |
All queries scoped to this.mount | Fixed ids break the second instance on the same page. this.mount.querySelector(...) never does |
Labels defaulted, then overridden by options.labels | Keeps localization out of the component while letting any page localize it |
| Escape every value it renders | It renders DB text into innerHTML; the local esc() is the JS twin of HttpUtility.HtmlEncode |
Small public API + destroy() | setBooks() to update, destroy() to unbind. Without destroy, re-mounting leaks listeners |
Paired stylesheet /css/components/<name>.css, every selector prefixed | StaticAsset.Component(name) pulls both; the prefix keeps its CSS from bleeding onto the page |
| No server assumptions | Endpoints and ids come from options. A component that hard-codes /api/books is a page file in disguise |
⚠️ CRITICAL: Data Crosses the Boundary Only as JSON
JsonConvert.SerializeObject is the only approved way to place a C# value into JavaScript. It handles quoting, escaping, Unicode, and </script> breakouts by construction:
// ✅ Safe for any value — numbers, strings, lists, objects
const title = {JsonConvert.SerializeObject(book.Title)};
const books = {JsonConvert.SerializeObject(lstBook)};
// ✅ Acceptable for values the server guarantees are numeric (int/parsed)
const member_id = {member.Id};
// ❌ NEVER — string concatenation of a value into a JS statement.
// A title containing ' or </script> breaks the page or executes as script.
const title = '{book.Title}';
This is the JavaScript twin of the HTML rule (HttpUtility.HtmlEncode for every dynamic HTML value). HTML values are encoded; JS values are JSON-serialized; no exceptions to either.
⚠️ CRITICAL: Execution Timing
Shared scripts (/js/site.js) are referenced at the end of <body> by GenerateHtmlFooter(). Therefore an inline block higher up the page must not call any function at parse time — helpers it depends on do not exist yet. Inline blocks declare data only; all execution starts from the external file, wired through DOMContentLoaded:
// ❌ In an inline block — runs at parse time, helpers not loaded yet
renderBooks(books);
// ✅ In the external file — runs after every script on the page is loaded
document.addEventListener('DOMContentLoaded', function () { renderBooks(books); });
The same ordering applies to components: new BookPicker(...) belongs inside DOMContentLoaded in the page file, never in the inline block.
Summary
| ❌ AVOID | ✅ USE INSTEAD |
|---|---|
| Static JS functions inside C# strings | External /js/*.js file |
<script src='/js/x.js'> with no version | StaticAsset.Script("/js/x.js") cache-buster |
var title = '{value}'; concatenation | const title = {JsonConvert.SerializeObject(value)}; |
| Data island placed after the script that reads it | Data island before the external reference |
| Inline block that calls functions at parse time | Declarations only inline; init via DOMContentLoaded in the file |
Manual ?v=2 version bumps | File write-time versioning — busts automatically on change |
One page's code parked in site.js | Dedicated /js/<page>.js, loaded only by that page |
A misc.js / common2.js catch-all | site.js for genuinely app-wide, page files for the rest |
| Copy-pasting a function between two page files | Promote it to site.js or to a component |
| Widget whose HTML is emitted by C# and whose JS lives in a page file | One /js/components/<name>.js that renders itself into a mount element |
Component reading books / member_id off the global scope | Component receiving them through options |
| Component querying by fixed id | Queries scoped to this.mount |
CSS Organization — Same Scope Rules
CSS follows Rules 3 and 4 unchanged — only the extension differs. (Rules 1 and 2 barely apply: there is no "static CSS in C# strings" temptation, and dynamic style values are a data island of their own, see below.)
| Scope | Home | Loaded by |
|---|---|---|
App-wide — reset, layout, typography, color variables, header/nav/footer, shared .btn / .card / .form-* | /css/site.css | GenerateHtmlHeader() — every page |
| Page-specific — selectors only this page's markup matches | /css/<page>.css, one dedicated file per page | That page's handler, via ExtraHeaderText |
| Component — the widget's own look | /css/components/<name>.css | StaticAsset.Component(name) |
/css/
site.css ← app-wide, loaded on every page
books.css ← BookPage only
member-profile.css ← MemberProfilePage only
/components/
book-picker.css ← ships with book-picker.js
image-uploader.css
The rule that matters: page-specific selectors never enter site.css. Every visitor downloads and parses site.css on every page. Cram each page's local rules into it and it bloats monotonically — a hundred pages of dead selectors on every request, and nobody can ever safely delete a rule because nobody can prove which page still needs it. A page's CSS in its own file is deletable the day the page is.
Load order is already correct. GenerateHtmlHeader() emits site.css first, then ExtraHeaderText. So page and component stylesheets land after the global one and win ties at equal specificity — page CSS can override shared CSS without !important.
Conventions
- Scope page CSS to a page-root class so its rules cannot leak into shared components. Give
PageTemplateaBodyClassproperty and emit<body class='{BodyClass}'>, then write.page-books .card { … }rather than a bare.card { … }that quietly restyles every other page's cards. - Prefix every component selector with the component name (
.bookpicker,.bookpicker-item). No bare element selectors (li { … }), and no styling of anything outside its own root. That is what lets a component drop onto an unfamiliar page without a visual audit. - A shared style belongs in
site.css— but only once it is genuinely shared. Two pages needing the same card is the promotion signal; one page anticipating a second is not. - Never emit a static
<style>block from C#. It is the CSS form of the Rule 2 anti-pattern — uncacheable, unlintable, requires a rebuild to change. - Dynamic style values are a data island too. A theme color from the DB is emitted as a CSS custom property, and the stylesheet consumes it; the rules stay in the file:
// ✅ C# emits the value only
sb.Append($@"
<style>:root {{ --brand: {HttpUtility.HtmlEncode(theme.BrandColor)}; }}</style>");
/* /css/site.css — the rules stay in the file */
.btn-primary { background: var(--brand, #E0F3EF); }
Note the doubled {{ }}: inside a $@"…" interpolated string, every literal CSS brace must be doubled. That escaping tax is itself an argument for keeping CSS out of C# — a plain @"…" block (no $) needs no doubling, which is why the three approved forms reserve @"…" for fully static markup.
- Version component and page CSS with
StaticAsset.Css, same as scripts — a redeployed stylesheet that browsers keep from cache looks exactly like a broken page.
Frontend Pattern — Fetch API
Data Loading (GET)
const API_URL = '/bookapi';
const response = await fetch(`${API_URL}?action=get_list&id=${id}`);
const data = await response.json();
if (data.success) {
// Render data.items to DOM
}
Data Saving (POST with FormData)
async function saveBook() {
var title = document.getElementById('book-title').value.trim();
var author = document.getElementById('book-author').value.trim();
var formData = new FormData();
formData.append('action', 'save-book');
formData.append('title', title);
formData.append('author', author);
try {
var response = await fetch(API_URL, {
method: 'POST',
// Important: Do NOT set Content-Type header when using FormData
// The browser will automatically set it to multipart/form-data with correct boundary
body: formData
});
if (!response.ok) {
throw new Error('Server responded with an error status');
}
var data = await response.json();
if (data.success) {
alert(data.message);
} else {
alert(data.message);
}
} catch (error) {
console.error('Fetch error:', error);
}
}
File Upload (XMLHttpRequest with Progress)
async function uploadFile() {
var fileInput = document.getElementById('fileUpload');
if (!fileInput.files.length) return;
var formData = new FormData();
formData.append('action', 'upload');
formData.append('file', fileInput.files[0]);
// Use XMLHttpRequest for progress tracking
var xhr = new XMLHttpRequest();
xhr.upload.onprogress = function (e) {
if (e.lengthComputable) {
var pct = Math.round((e.loaded / e.total) * 100);
document.getElementById('progress').textContent = pct + '%';
}
};
xhr.onload = function () {
var data = JSON.parse(xhr.responseText);
if (data.success) alert('Uploaded');
else alert(data.message);
};
xhr.open('POST', API_URL);
xhr.send(formData);
}
HTML Escaping in JavaScript
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
Backend API Pattern
API Handler Structure
public class BookPageApi
{
public static void HandleRequest()
{
var Request = HttpContext.Current.Request;
string action = (Request["action"] + "").ToLower().Trim();
try
{
switch (action)
{
case "get-books-html": GetBooksHtml(); break;
case "get-books-json": GetBooksJson(); break;
case "get-book": GetBook(); break;
case "save-book": SaveBook(); break;
case "delete-book": DeleteBook(); break;
default: ApiHelper.WriteError($"Unknown action: {action}", 400); break;
}
}
catch (Exception ex)
{
ApiHelper.WriteError(ex.Message, 500);
}
ApiHelper.EndResponse();
}
}
READ — Return HTML Fragment
The server pre-renders HTML. The frontend dumps it into a container with innerHTML:
Backend:
static void GetBooksHtml()
{
HttpResponse Response = HttpContext.Current.Response;
StringBuilder sb = new StringBuilder();
List<obBook> lstBook = GetBooksFromDatabase();
foreach (var b in lstBook)
{
sb.Append($@"
<div class='card-book'>
<strong>{HttpUtility.HtmlEncode(b.Title)}</strong><br>
Author: {HttpUtility.HtmlEncode(b.Author)}<br>
Year: {b.Year}<br>
<button type='button' onclick='editBook({b.Id})'>Edit</button>
<button type='button' onclick='deleteBook({b.Id})'>Delete</button>
</div>");
}
Response.ContentType = "text/html; charset=utf-8";
Response.Write(sb.ToString());
}
Frontend:
async function getAllBooksHtml() {
var formData = new FormData();
formData.append('action', 'get-books-html');
try {
var response = await fetch(API_URL, {
method: 'POST',
body: formData
});
var html = await response.text();
document.getElementById('div-my-books').innerHTML = html;
} catch (e) {
alert('Failed to load books');
}
}
READ — Return JSON
The server returns data. The frontend renders it in JavaScript:
Backend:
static void GetBooksJson()
{
List<obBook> lstBook = GetBooksFromDatabase();
ApiHelper.WriteJson(new
{
success = true,
message = "Success",
books = lstBook
});
}
Frontend:
async function getAllBooksJson() {
var formData = new FormData();
formData.append('action', 'get-books-json');
try {
var response = await fetch(API_URL, {
method: 'POST',
body: formData
});
var data = await response.json();
if (!data.success) {
alert(data.message);
return;
}
var blocks = [];
for (var i = 0; i < data.books.length; i++) {
var b = data.books[i];
blocks.push(
"<div class='card-book'>" +
"<strong>" + escapeHtml(b.Title) + "</strong><br>" +
"Author: " + escapeHtml(b.Author) + "<br>" +
"Year: " + b.Year + "<br>" +
"<button type='button' onclick='editBook(" + b.Id + ")'>Edit</button> " +
"<button type='button' onclick='deleteBook(" + b.Id + ")'>Delete</button>" +
"</div>"
);
}
document.getElementById('div-my-books').innerHTML = blocks.join('');
} catch (e) {
alert('Failed to load books');
}
}
CREATE + UPDATE (2-in-1): SaveBook
If id is empty or zero → INSERT (Create). If id has a value → UPDATE.
Backend:
static void SaveBook()
{
HttpRequest Request = HttpContext.Current.Request;
int id = 0;
int.TryParse(Request.Form["id"] + "", out id);
string title = (Request.Form["title"] + "").Trim();
string author = (Request.Form["author"] + "").Trim();
int year = 0;
int.TryParse(Request.Form["year"] + "", out year);
// Validation
if (string.IsNullOrEmpty(title))
{
ApiHelper.WriteError("Title is required");
return;
}
if (string.IsNullOrEmpty(author))
{
ApiHelper.WriteError("Author is required");
return;
}
if (year < 1 || year > DateTime.Now.Year)
{
ApiHelper.WriteError("Please enter a valid year");
return;
}
using (MySqlConnection conn = new MySqlConnection(config.ConnString))
{
conn.Open();
using (MySqlCommand cmd = new MySqlCommand())
{
cmd.Connection = conn;
MySqlExpress m = new MySqlExpress(cmd);
Dictionary<string, object> dic = new Dictionary<string, object>();
dic["title"] = title;
dic["author"] = author;
dic["year"] = year;
if (id <= 0)
{
// CREATE — no id, insert new row
m.Insert("books", dic);
ApiHelper.WriteSuccess("Book added");
}
else
{
// UPDATE — has id, update existing row
m.Update("books", dic, "id", id);
ApiHelper.WriteSuccess("Book updated");
}
}
}
}
Frontend:
async function saveBook() {
var id = document.getElementById('book-id').value.trim();
var title = document.getElementById('book-title').value.trim();
var author = document.getElementById('book-author').value.trim();
var year = document.getElementById('book-year').value.trim();
var formData = new FormData();
formData.append('action', 'save-book');
formData.append('id', id); // empty = create, has value = update
formData.append('title', title);
formData.append('author', author);
formData.append('year', year);
try {
var response = await fetch(API_URL, {
method: 'POST',
body: formData
});
var data = await response.json();
if (data.success) {
alert(data.message);
clearForm();
getAllBooksHtml(); // Refresh the list
} else {
alert(data.message);
}
} catch (e) {
alert('Something went wrong. Please try again.');
}
}
DELETE
Backend:
static void DeleteBook()
{
HttpRequest Request = HttpContext.Current.Request;
int id = 0;
int.TryParse(Request.Form["id"] + "", out id);
if (id <= 0)
{
ApiHelper.WriteError("Invalid book ID");
return;
}
using (MySqlConnection conn = new MySqlConnection(config.ConnString))
{
conn.Open();
using (MySqlCommand cmd = new MySqlCommand())
{
cmd.Connection = conn;
MySqlExpress m = new MySqlExpress(cmd);
var p = new Dictionary<string, object>();
p["@id"] = id;
m.Execute("DELETE FROM books WHERE id = @id;", p);
}
}
ApiHelper.WriteSuccess("Book deleted");
}
Frontend:
async function deleteBook(id) {
if (!id) {
id = document.getElementById('book-id').value.trim();
}
if (!id) {
alert('No book selected');
return;
}
if (!confirm('Delete this book?')) return;
var formData = new FormData();
formData.append('action', 'delete-book');
formData.append('id', id);
try {
var response = await fetch(API_URL, {
method: 'POST',
body: formData
});
var data = await response.json();
if (data.success) {
alert(data.message);
clearForm();
getAllBooksHtml();
} else {
alert(data.message);
}
} catch (e) {
alert('Something went wrong. Please try again.');
}
}
API Response Format
All API responses follow this JSON structure:
// Success
{ "success": true, "message": "..." }
// Success with data
{ "success": true, "message": "...", "data": {...} }
// Success with list
{ "success": true, "items": [...] }
// Error
{ "success": false, "message": "Error description" }
File Upload Pattern
Frontend:
async function uploadFile() {
var fileInput = document.getElementById('fileUpload');
if (!fileInput.files.length) return;
var formData = new FormData();
formData.append('action', 'upload');
formData.append('parent_id', parentId);
formData.append('file', fileInput.files[0]);
var xhr = new XMLHttpRequest();
xhr.upload.onprogress = function (e) {
if (e.lengthComputable) {
var pct = Math.round((e.loaded / e.total) * 100);
document.getElementById('progress').textContent = pct + '%';
}
};
xhr.onload = function () {
var data = JSON.parse(xhr.responseText);
if (data.success) alert('Uploaded');
else alert(data.message);
};
xhr.open('POST', API_URL);
xhr.send(formData);
}
Backend:
static void UploadFile()
{
HttpRequest Request = HttpContext.Current.Request;
if (Request.Files.Count == 0)
{
ApiHelper.WriteError("No file uploaded");
return;
}
var uploadedFiles = new List<object>();
for (int i = 0; i < Request.Files.Count; i++)
{
HttpPostedFile file = Request.Files[i];
if (file.ContentLength == 0)
continue;
string fileName = Path.GetFileName(file.FileName);
string savePath = HttpContext.Current.Server.MapPath("~/uploads/" + fileName);
try
{
file.SaveAs(savePath);
uploadedFiles.Add(new
{
success = true,
fileName = fileName,
filePath = "/uploads/" + fileName
});
}
catch (Exception ex)
{
uploadedFiles.Add(new
{
success = false,
fileName = fileName,
message = ex.Message
});
}
}
ApiHelper.WriteJson(uploadedFiles);
}
Fire-and-Forget Background Task
For non-blocking background work that doesn't need to return results to frontend:
_ = Task.Run(() => DoWork(taskId)); // Fire-and-forget, returns immediately
Tracking Task State
When the frontend needs to start and cancel a long-running job, keep the state of each job in a static ConcurrentDictionary keyed by task id, and drive it through the normal HTTP action switch.
TaskInfo Class
class TaskInfo
{
public int TaskId { get; set; }
public int PercentComplete { get; set; } = 0;
public string Status { get; set; } = "Running";
public bool IsCompleted { get; set; } = false;
public bool HasError { get; set; } = false;
public string ErrorMessage { get; set; } = "";
public bool RequestCancel { get; set; } = false;
public bool IsCancelled { get; set; } = false;
}
Backend:
public class TaskApi
{
static ConcurrentDictionary<int, TaskInfo> dicTaskInfo
= new ConcurrentDictionary<int, TaskInfo>();
public static void HandleRequest()
{
var Request = HttpContext.Current.Request;
string action = (Request["action"] + "").ToLower().Trim();
try
{
switch (action)
{
case "start-task": StartTask(); break;
case "stop-task": StopTask(); break;
default: ApiHelper.WriteError("Unknown action", 400); break;
}
}
catch (Exception ex) { ApiHelper.WriteError(ex.Message, 500); }
ApiHelper.EndResponse();
}
static void StartTask()
{
int taskId = GetNewTaskId();
var taskInfo = new TaskInfo { TaskId = taskId };
dicTaskInfo[taskId] = taskInfo;
_ = Task.Run(() => DoWork(taskId));
ApiHelper.WriteJson(new { success = true, taskId });
}
static void StopTask()
{
var Request = HttpContext.Current.Request;
int taskId = 0;
int.TryParse(Request["task_id"] + "", out taskId);
if (dicTaskInfo.TryGetValue(taskId, out var taskInfo))
{
taskInfo.RequestCancel = true;
ApiHelper.WriteSuccess("Stop requested");
}
else
{
ApiHelper.WriteError("Task not found");
}
}
static void DoWork(int taskId)
{
if (!dicTaskInfo.TryGetValue(taskId, out var taskInfo)) return;
try
{
for (int i = 0; i <= 100; i += 10)
{
if (taskInfo.RequestCancel)
{
taskInfo.IsCancelled = true;
break;
}
taskInfo.PercentComplete = i;
Thread.Sleep(500); // Simulate work
}
}
catch (Exception ex)
{
taskInfo.HasError = true;
taskInfo.ErrorMessage = ex.Message;
}
taskInfo.IsCompleted = true;
}
}
Frontend:
const API_URL = '/taskapi';
async function startTask() {
var formData = new FormData();
formData.append('action', 'start-task');
try {
var response = await fetch(API_URL, {
method: 'POST',
body: formData
});
var data = await response.json();
if (data.success) {
console.log('Task started:', data.taskId);
}
} catch (error) {
console.error('Error starting task:', error);
}
}
The Rendering Pipeline at a Glance
HTTP Request arrives at IIS
│
▼
IIS parses raw TCP bytes into HttpContext.Current.Request
│
▼
ASP.NET pipeline begins
│
├──► Application_BeginRequest
│ └── ALL routes handled here (built-in session module disabled)
│ │
│ ▼
│ AppSession.TryRestoreFromCookie()
│ └── Custom SessionStore resolves "ssid" cookie →
│ ConcurrentDictionary lookup; on miss, optionally
│ rehydrate logged-in user from DB via "lsid" cookie
│ │
│ ▼
│ PageTemplate generates <head> + navbar
│ │
│ ▼
│ Handler method builds page-specific content
│ via StringBuilder (database queries, loops, conditionals)
│ │
│ ▼
│ PageTemplate generates footer + scripts + closing tags
│ │
│ ▼
│ Response.Write(sb.ToString())
│ │
│ ▼
│ ApiHelper.EndResponse()
│ └── CompleteRequest() → skip to EndRequest
│
├──► [Page handler execution — SKIPPED]
│
├──► EndRequest (cleanup)
│
▼
HTTP Response sent to browser
The page handler execution step — where conventional Web Forms would instantiate a System.Web.UI.Page subclass, build the control tree, process ViewState, and run the full page lifecycle — is entirely skipped. The request goes from "session available" to "response sent" in a single method call.
C# Conventions
Do not use dynamic-typed reference.
| Cost | Why it matters |
|---|---|
| No IntelliSense | You lose autocomplete, refactoring, "Find All References" — the IDE goes blind |
| Runtime errors | Typos like arg.blakc compile fine, crash only when that line executes |
| Slower | Each .member access goes through the DLR CallSite cache — adds ~microseconds |
| No null-safety | The C# 8+ nullable reference types feature can't help you |
| Hard to grep | Static analysis tools, dependency analyzers, and AI assistants struggle |
No dynamic | Decompile-Hostile: Compiles into heavy DLR CallSite plumbing that ILSpy cannot cleanly reconstruct. Also destroys IntelliSense, static analysis, and runtime performance. |
| No LINQ | GC & Allocation Heavy: LINQ creates delegate allocations and closure classes (<>c__DisplayClass). Traditional for/foreach loops eliminate GC pressure and decompile into 100% clean C# in ILSpy. |
Photo by zhang kaiyv on Unsplash