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
```html
<!-- ❌ WRONG: Triggers postback (default type="submit") -->
<button onclick="saveItem()">Save</button>
<!-- ✅ CORRECT: Executes JavaScript only, no postback -->
<button type="button" onclick="saveItem()">Save</button>
```
**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
```csharp
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)**
```javascript
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)**
```javascript
// 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)**
```javascript
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:
```html
<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="apiPage.aspx.cs" Inherits="myweb.apiBackup" %>
```
The code behind (.aspx.cs)
```csharp
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:
```javascript
// 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:
```csharp
_ = Task.Run(() => DoWork(taskId)); // Fire-and-forget, returns immediately
```
---
## File Upload Pattern
**Frontend:**
```javascript
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:**
```csharp
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
| Method | Direction | Use Case |
|--------|-----------|----------|
| **HTTP/Fetch API** | Request → Response | CRUD operations, start/stop tasks |
| **WebSocket** | Bi-directional | Real-time chat, interactive updates |
| **Server-Sent Events (SSE)** | Server → Client only | Progress 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)
```html
<%@ 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
```csharp
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
```csharp
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
```javascript
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
```csharp
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
```javascript
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:
```csharp
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
```csharp
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
| Criteria | SSE | WebSocket |
|----------|-----|-----------|
| Progress reporting | ✅ Best choice | Works but overkill |
| Server → Client only | ✅ Best choice | Unnecessary complexity |
| Bi-directional chat | ✗ Not supported | ✅ Best choice |
| Browser support | Excellent | Excellent |
| Implementation complexity | Simple | More complex |
| Automatic reconnection | ✅ Built-in | Manual 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.
