Complete Architecture Reference for Vanilla ASP.NET Web Forms in MD (Markdown) Format

Mar 23, 2026
Updated Jun 26, 2026
adriancs

This is a complete architecture reference, guideline and code convention for building modern web applications on Vanilla ASP.NET Web Forms without Server Controls, ViewState, or PostBack. It covers API endpoint patterns, Fetch API integration, file uploads, Server-Sent Events, WebSocket, and background task management.

# Vanilla ASP.NET Web Forms API Endpoint+ FetchAPI Architecture

## Overview

This project uses a **hybrid architecture** that combines ASP.NET WebForms infrastructure with modern client-side API patterns. This approach eliminates ViewState, server controls, and postbacks while retaining WebForms benefits (routing, authentication, master pages).

---

## Core Principle: NO Traditional WebForms Patterns

| ❌ AVOID | ✅ USE INSTEAD |
|----------|----------------|
| `<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()` or `XMLHttpRequest` |
| Code-behind event handlers | API endpoint actions |

### ⚠️ CRITICAL: Button Type Declaration
```

Always use type="button" on all <button> elements. Without it, the browser defaults to type="submit", which triggers a form postback and breaks the Fetch API pattern.


Default JSON Library

NewtonSoft.JSON

using Newtonsoft.Json;

Response.ContentType = "application/json";
Response.Write(JsonConvert.SerializeObject(obj));

JSON naming convention. Direct matching 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 the CamelCase ("propertyName").


File Structure Pattern

Master Page as Template

Use ASP.NET Master Pages (.master) as the shared layout template.
The master page holds the common HTML structure — <head>, navigation,
footer, script references — while each content page supplies its own
markup through ContentPlaceHolder regions.

Page Naming

Frontend Page Naming

Frontend with HTML, JavaScript and CSS only

FrontPage.aspx
FrontPage.aspx.cs <-- almost empty, except user login detection
FrontPage.aspx.designer.cs <-- empty component, because there is no server control

API Page Naming

Primary Choice, append with "Api" as a pair to the Frontend Page. Easy to manage.

FrontPageApi.aspx <-- blank, nothing
FrontPageApi.aspx.cs <-- backend C# api handling
FrontPageApi.aspx.designer.cs <-- empty component, because there is no server control

Secondary Choice, prefix with "api":

apiFrontPage.aspx <-- blank, nothing
apiFrontPage.aspx.cs <-- backend C# api handling
apiFrontPage.aspx.designer.cs <-- empty component, because there is no server control

Frontend Pattern (.aspx)

Key Frontend Patterns

1. Data Loading (GET)

const response = await fetch(`${API_URL}?action=get_list&id=${id}`);
const data = await response.json();
if (data.success) {
    // Render data.items to DOM
}

2. Data Saving (POST with FormData)


// uses the async/await syntax
// making asynchronous code look and behave more like synchronous code.
// Linear Flow: You read the code from top to bottom. There is no "nesting" or "callback hell."
// Variable Scoping: Variables like data or response are available in the same scope, making it easier to use them later in the function without passing them through a chain of .then() blocks.
// Error Handling: You can use standard try/catch blocks, which many developers find more intuitive than a trailing .catch() method.
async function doRegister() {

    // Prevent double submit
    const btn = document.getElementById('btnRegister');
    btn.disabled = true;
    btn.textContent = 'Creating account...';

    // append the form field (if relevant) or fake form fields as honeypot to trap spambot
    const form = document.getElementById('registerForm');

    const formData = new FormData(form);
    formData.append('action', 'register');
    formData.append('username', username);
    formData.append('email', email);
    formData.append('password', password);
    formData.append('subscribe_newsletter', newsletter);

    try {
        const 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');
        }

        const data = await response.json();

        if (data.success) {
            // task success
            btn.textContent = 'Done';
        } else {
            // task failed
            btn.disabled = false;
            btn.textContent = 'Register';
        }
    } catch (error) {
        console.error('Fetch error:', error);
        // network error
        btn.disabled = false;
        btn.textContent = 'Register';
    }
}

3. File Upload (XMLHttpRequest with FormData)

const formData = new FormData();
formData.append('action', 'upload');
formData.append('file', fileInput.files[0]);

const xhr = new XMLHttpRequest();
xhr.open('POST', API_URL);
xhr.onload = function() {
    const data = JSON.parse(xhr.responseText);
    // Handle response
};
xhr.send(formData);

Backend Api Pattern

Structure

Front page (.aspx)

Deletes all frontend markup, leave only the first line, the page directive declaration:

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="apiPage.aspx.cs" Inherits="myweb.apiBackup" %>

The code behind (.aspx.cs)

public partial class SomePageApi : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        try
        {
            // 1. Auth check
            if (AppSession.LoginUser == null || !AppSession.LoginUser.IsAdmin)
            {
                WriteError("Unauthorized", 401);
                return;
            }

            // 2. Route by action parameter
            string action = (Request["action"] + "").ToLower().Trim();
            
            switch (action)
            {
                case "get_list":    GetList();     break;
                case "get_item":    GetItem();     break;
                case "save":        SaveItem();    break;
                case "delete":      DeleteItem();  break;
                default:            WriteError($"Unknown action: {action}", 400); break;
            }
        }
        catch (Exception ex)
        {
            WriteError(ex.Message, 500);
        }

        EndResponse();
    }

    #region Helper Methods (Copy to all API files)

    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();
    }

    void WriteJson(object obj)
    {
        // no naming conversion, preserves names exactly as declared:
        Response.ContentType = "application/json";
        Response.Write(JsonConvert.SerializeObject(obj));
    }

    void WriteSuccess(string message = "Success")
    {
        WriteJson(new { success = true, message });
    }

    void WriteError(string message, int statusCode = 400)
    {
        Response.StatusCode = statusCode;
        WriteJson(new { success = false, message });
    }

    #endregion

    #region API Actions

    void GetList()
    {
        int parentId = dp.IntParse(Request["parent_id"]);
        // ... fetch from database
        WriteJson(new { success = true, items = resultList });
    }

    void SaveItem()
    {
        // Read parameters
        int id = dp.IntParse(Request["id"]);
        string name = (Request["name"] + "").Trim();
        
        // Validate
        if (string.IsNullOrEmpty(name))
        {
            WriteError("Name is required");
            return;
        }
        
        // Save to database using MySqlExpress
        using (MySqlConnection conn = new MySqlConnection(config.ConnString))
        {
            conn.Open();
            using (MySqlCommand cmd = new MySqlCommand())
            {
                cmd.Connection = conn;
                MySqlExpress m = new MySqlExpress(cmd);
                m.Save("table_name", itemObject);
            }
        }
        
        WriteSuccess("Saved");
    }

    #endregion
}

API Response Format

All API responses follow this JSON structure:

// Success
{ "success": true, "message": "...", "data": {...} }

// Success with list
{ "success": true, "items": [...] }

// Error
{ "success": false, "message": "Error description" }

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

File Upload Pattern

Frontend:

async function uploadFile() {
    const fileInput = document.getElementById('fileUpload');
    if (!fileInput.files.length) return;
    
    const formData = new FormData();
    formData.append('action', 'upload');
    formData.append('parent_id', parentId);
    formData.append('file', fileInput.files[0]);
    
    // Use XMLHttpRequest for progress tracking
    const xhr = new XMLHttpRequest();
    
    xhr.upload.onprogress = (e) => {
        if (e.lengthComputable) {
            const pct = Math.round((e.loaded / e.total) * 100);
            document.getElementById('progress').textContent = pct + '%';
        }
    };
    
    xhr.onload = () => {
        const data = JSON.parse(xhr.responseText);
        if (data.success) showToast('Uploaded', 'success');
        else showToast(data.message, 'error');
    };
    
    xhr.open('POST', API_URL);
    xhr.send(formData);
}

Backend:

void UploadFile()
{
    if (Request.Files.Count == 0)
    {
        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; // skip empty files if desired

        string fileName = Path.GetFileName(file.FileName);
        string savePath = Server.MapPath("~/uploads/" + fileName);

        try
        {
            file.SaveAs(savePath);

            uploadedFiles.Add(new
            {
                success = true,
                fileName = fileName,
                filePath = "/uploads/" + fileName
            });
        }
        catch (Exception ex)
        {
            // Log exception; decide whether to continue or fail the whole batch
            uploadedFiles.Add(new
            {
                success = false,
                fileName = fileName,
                message = ex.Message
            });
        }
    }

    // Return array of results (most flexible for client)
    WriteJson(uploadedFiles);
    // Or, if you prefer a single success flag:
    // bool allSuccess = uploadedFiles.All(f => (bool)f.success);
    // WriteJson(new { success = allSuccess, files = uploadedFiles });
}

Real-Time Communication (WebSocket & SSE)

For long-running tasks that require progress reporting, use Server-Sent Events (SSE) or WebSocket connections instead of polling.

Communication Methods

MethodDirectionUse Case
HTTP/Fetch APIRequest → ResponseCRUD operations, start/stop tasks
WebSocketBi-directionalReal-time chat, interactive updates
Server-Sent Events (SSE)Server → Client onlyProgress reporting, status updates

Recommendation: Default to SSE for progress reporting (simpler, automatic reconnection). Use WebSocket only when bi-directional communication is required.


Architecture: Hybrid HTTP + Streaming

Real-time features combine HTTP requests with streaming connections:

  • HTTP Request: Start task, stop task, retrieve initial data
  • SSE/WebSocket: Monitor progress, receive real-time updates
┌─────────────┐     HTTP POST (start_task)      ┌─────────────┐
│   Frontend  │ ─────────────────────────────▶  │   Backend   │
│             │     { taskId: 123 }              │   API       │
│             │ ◀─────────────────────────────   │             │
│             │                                  │             │
│             │     SSE/WebSocket Connection     │             │
│             │ ◀════════════════════════════   │             │
│             │     { progress: 50%, ... }       │             │
└─────────────┘                                  └─────────────┘

A single API endpoint handles both HTTP and streaming requests.


Server-Sent Events (SSE) — Recommended

Page Directive (CRITICAL)

<%@ Page Language="C#" EnableSessionState="ReadOnly" AutoEventWireup="true" 
    CodeBehind="SomeApi.aspx.cs" Inherits="..." %>

⚠️ EnableSessionState="ReadOnly" is mandatory to permit concurrent HTTP requests while an SSE connection is active. Without this, ASP.NET session locking will block all other requests.

Backend: SSE Detection & Handling

public partial class SomeApi : System.Web.UI.Page
{
    static ConcurrentDictionary<int, TaskInfo> dicTaskInfo = new ConcurrentDictionary<int, TaskInfo>();

    protected void Page_Load(object sender, EventArgs e)
    {
        // 1. Check for SSE request FIRST
        if (Request.Headers["Accept"] == "text/event-stream" || Request["stream"] == "true")
        {
            HandleSSERequest();
            return;
        }

        // 2. Normal HTTP API handling
        try
        {
            if (!IsAuthenticated()) { WriteError("Unauthorized", 401); return; }

            string action = (Request["action"] + "").ToLower().Trim();
            switch (action)
            {
                case "start_task":  StartTask();  break;
                case "stop_task":   StopTask();   break;
                default: WriteError("Unknown action", 400); break;
            }
        }
        catch (Exception ex) { WriteError(ex.Message, 500); }

        EndResponse();
    }
}

Backend: SSE Handler

void HandleSSERequest()
{
    if (!IsAuthenticated())
    {
        Response.StatusCode = 401;
        EndResponse();
        return;
    }

    if (!int.TryParse(Request["task_id"] + "", out int taskId))
    {
        Response.StatusCode = 400;
        EndResponse();
        return;
    }

    // SSE Headers
    Response.ContentType = "text/event-stream";
    Response.CacheControl = "no-cache";
    Response.AddHeader("Connection", "keep-alive");
    Response.Buffer = false;

    try
    {
        SendSSEEvent("connected", $"Subscribed to task {taskId}");

        while (Response.IsClientConnected)
        {
            if (dicTaskInfo.TryGetValue(taskId, out var taskInfo))
            {
                SendSSEEvent("progress", JsonConvert.SerializeObject(taskInfo));

                if (taskInfo.IsCompleted)
                {
                    SendSSEEvent("completed", JsonConvert.SerializeObject(taskInfo));
                    break;
                }
            }
            Thread.Sleep(250);  // Poll interval
        }
    }
    catch (HttpException) { /* Client disconnected */ }
    finally { EndResponse(); }
}

void SendSSEEvent(string eventType, string data)
{
    if (!Response.IsClientConnected) return;
    Response.Write($"event: {eventType}\ndata: {data}\n\n");
    Response.Flush();
}

Frontend: SSE Connection

const API_URL = '/pages/SomeApi.aspx';
let eventSource = null;
let currentTaskId = 0;

// Start task via HTTP POST with FormData, then connect SSE
async function startTask() {
    const formData = new FormData();
    formData.append('action', 'start_task');

    try {
        const response = await fetch(API_URL, {
            method: 'POST',
            // Do NOT set Content-Type header when using FormData
            // The browser automatically sets multipart/form-data with boundary
            body: formData
        });

        if (!response.ok) {
            throw new Error(`Server responded with status ${response.status}`);
        }

        const data = await response.json();

        if (data.success) {
            currentTaskId = data.taskId;
            connectSSE(currentTaskId);
        } else {
            console.error('Task start failed:', data);
            // Optionally show user feedback here
        }
    } catch (error) {
        console.error('Error starting task:', error);
        // Optionally show user feedback here
    }
}

function connectSSE(taskId) {
    if (eventSource) return;

    eventSource = new EventSource(`${API_URL}?stream=true&task_id=${taskId}`);

    eventSource.addEventListener('connected', (e) => {
        console.log('SSE connected:', e.data);
    });

    eventSource.addEventListener('progress', (e) => {
        const data = JSON.parse(e.data);
        updateProgress(data.percentComplete, data.status);
    });

    eventSource.addEventListener('completed', (e) => {
        const data = JSON.parse(e.data);
        updateProgress(100, 'Completed');
        closeSSE();
    });

    eventSource.onerror = () => {
        console.error('SSE error');
        closeSSE();
    };
}

function closeSSE() {
    if (eventSource) {
        eventSource.close();
        eventSource = null;
    }
}

// Cleanup on page unload
window.addEventListener('beforeunload', closeSSE);

WebSocket — For Bi-Directional Communication

Use WebSocket when the client needs to send messages to the server during an active connection.

Backend: WebSocket Detection & Handling

protected void Page_Load(object sender, EventArgs e)
{
    // 1. Check for WebSocket request FIRST
    if (Context.IsWebSocketRequest)
    {
        Context.AcceptWebSocketRequest(HandleWebSocket);
        return;
    }

    // 2. Normal HTTP API handling
    // ... same as SSE pattern
}

async Task HandleWebSocket(AspNetWebSocketContext context)
{
    WebSocket webSocket = context.WebSocket;

    if (!IsAuthenticated())
    {
        await webSocket.CloseAsync(WebSocketCloseStatus.PolicyViolation, 
            "Unauthorized", CancellationToken.None);
        return;
    }

    byte[] buffer = new byte[1024];

    while (webSocket.State == WebSocketState.Open)
    {
        var result = await webSocket.ReceiveAsync(
            new ArraySegment<byte>(buffer), CancellationToken.None);

        if (result.MessageType == WebSocketMessageType.Text)
        {
            string message = Encoding.UTF8.GetString(buffer, 0, result.Count);
            
            if (message.StartsWith("taskid:"))
            {
                int taskId = int.Parse(message.Substring(7));
                await SendProgressUpdates(webSocket, taskId);
            }
        }
        else if (result.MessageType == WebSocketMessageType.Close)
        {
            await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, 
                "", CancellationToken.None);
            break;
        }
    }
}

async Task SendProgressUpdates(WebSocket webSocket, int taskId)
{
    while (webSocket.State == WebSocketState.Open)
    {
        if (dicTaskInfo.TryGetValue(taskId, out var taskInfo))
        {
            string json = JsonConvert.SerializeObject(taskInfo);
            byte[] bytes = Encoding.UTF8.GetBytes(json);
            
            await webSocket.SendAsync(new ArraySegment<byte>(bytes),
                WebSocketMessageType.Text, true, CancellationToken.None);

            if (taskInfo.IsCompleted) break;
        }
        await Task.Delay(250);
    }
}

Frontend: WebSocket Connection

let webSocket = null;

function connectWebSocket(taskId) {
    if (webSocket) return;

    const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
    const wsUrl = `${protocol}//${location.host}${API_URL}`;

    webSocket = new WebSocket(wsUrl);

    webSocket.onopen = () => {
        webSocket.send(`taskid:${taskId}`);
    };

    webSocket.onmessage = (event) => {
        const data = JSON.parse(event.data);
        updateProgress(data.percentComplete, data.status);
        
        if (data.isCompleted) {
            webSocket.close(1000, "Task completed");
        }
    };

    webSocket.onclose = () => { webSocket = null; };
    webSocket.onerror = (err) => { console.error('WebSocket error:', err); };
}

TaskInfo Class

Standard structure for tracking background task state:

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; } = "";
    
    // For graceful cancellation
    public bool RequestCancel { get; set; } = false;
    public bool IsCancelled { get; set; } = false;
}

Background Task with Cancellation Support

void StartTask()
{
    int taskId = GetNewTaskId();
    
    var taskInfo = new TaskInfo { TaskId = taskId };
    dicTaskInfo[taskId] = taskInfo;

    // Start background work (fire-and-forget)
    _ = Task.Run(() => DoWork(taskId));

    // Return immediately with task ID
    WriteJson(new { success = true, taskId });
}

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

void StopTask()
{
    int taskId = dp.IntParse(Request["task_id"]);
    if (dicTaskInfo.TryGetValue(taskId, out var taskInfo))
    {
        taskInfo.RequestCancel = true;
        WriteSuccess("Stop requested");
    }
    else
    {
        WriteError("Task not found");
    }
}

SSE vs WebSocket Decision Matrix

CriteriaSSEWebSocket
Progress reporting✅ Best choiceWorks but overkill
Server → Client only✅ Best choiceUnnecessary complexity
Bi-directional chat✗ Not supported✅ Best choice
Browser supportExcellentExcellent
Implementation complexitySimpleMore complex
Automatic reconnection✅ Built-inManual implementation required

Default to SSE for progress reporting. Use WebSocket only when the client must send messages during an active connection.

Or use normal form post (fetchapi) to send message and SSE to receive changes or updates from server.


Photo by Beyzaa Yurtkuran from [Pexels](https://www.pexels.com/photo/hand-carved-decorative-wooden-panels-in-workshop-19208266/).