API Styles Complete Guide: REST, Minimal API, GraphQL, gRPC, WebSocket, and More
Every API style explained with real-world examples ā REST, Minimal API, GraphQL, gRPC, WebSocket, SignalR, Webhook. When to use each, how they differ in design, performance, and security, with full code in .NET, Python, and TypeScript and real examples from Stripe, GitHub, Uber, Slack, and Netflix.
Why API Style Matters
An API is a contract between a producer and a consumer. The style you choose determines:
- How data is requested and shaped
- How much bandwidth is used
- Whether communication is real-time or request/response
- How easy it is to evolve without breaking clients
- How discoverable and self-documenting it is
Choosing the wrong style creates pain that compounds over years. Choosing REST for a real-time chat app means polling. Choosing WebSockets for a simple CRUD app means unnecessary complexity. Choosing GraphQL for a simple mobile app with two screens means overkill that costs the team months.
Communication Styles:
Request/Response (synchronous):
REST ā resource-oriented, stateless, HTTP verbs
Minimal API ā same as REST but lighter framework overhead
GraphQL ā client defines exactly what data it needs
gRPC ā binary, strongly typed, generated clients, fast
Real-Time (persistent connection):
WebSocket ā full-duplex, any data, any direction
SignalR ā WebSocket abstraction with fallbacks (.NET)
SSE ā server pushes events, client listens (one-way)
Event-Driven (fire and forget):
Webhooks ā server pushes to your endpoint when something happens
Message Queues ā covered in messaging guideREST API ā The Foundation
REST (Representational State Transfer) is not a protocol or a specification. It is a set of architectural constraints that, when followed, produce a predictable, scalable API.
The Six REST Constraints
1. Stateless: Every request contains all information needed to process it. The server stores no session state between requests. Authentication goes in every request header ā not in a server-side session.
WRONG (stateful):
POST /login ā server stores session
GET /my-orders ā server looks up session to know who "me" is
CORRECT (stateless):
GET /orders ā Authorization: Bearer
Every request is self-contained 2. Uniform Interface: Resources are identified by URIs. The same HTTP verbs mean the same thing everywhere.
GET /appointments ā list appointments
GET /appointments/123 ā get appointment 123
POST /appointments ā create appointment
PUT /appointments/123 ā replace appointment 123 entirely
PATCH /appointments/123 ā update specific fields of appointment 123
DELETE /appointments/123 ā delete appointment 1233. Client-Server: Client and server are independent. The client does not know how the server stores data. The server does not know how the client renders it.
4. Cacheable: Responses declare whether they can be cached. GET requests are cacheable. POST/PUT/DELETE are not.
5. Layered System: A client does not know if it is talking to the real server, a load balancer, a cache, or an API gateway. Each layer handles its concern.
6. Code on Demand (optional): Server can send executable code to the client (rarely used).
REST Resource Design
The most common mistake in REST is using verbs in URLs:
WRONG (RPC-style, not REST):
POST /createAppointment
POST /cancelAppointment
POST /getAvailableSlots
POST /sendConfirmationEmail
CORRECT (resource-oriented):
POST /appointments create appointment
PATCH /appointments/123 { status: cancelled } cancel
GET /slots?date=2026-04-21&practice=p1 get available slots
POST /appointments/123/notifications trigger notificationNouns, not verbs. Resources, not actions.
REST in .NET ā Full Controller Pattern
[ApiController]
[Route("api/v1/[controller]")]
[Authorize]
public class AppointmentsController : ControllerBase
{
private readonly IAppointmentService _service;
private readonly ILogger<AppointmentsController> _logger;
public AppointmentsController(IAppointmentService service, ILogger<AppointmentsController> logger)
{
_service = service;
_logger = logger;
}
/// <summary>List appointments for the authenticated patient.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResult<AppointmentDto>), 200)]
public async Task<IActionResult> GetAll(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var appointments = await _service.GetForPatientAsync(
UserId(), from, to, page, pageSize);
return Ok(appointments);
}
/// <summary>Get a specific appointment.</summary>
[HttpGet("{id}")]
[ProducesResponseType(typeof(AppointmentDto), 200)]
[ProducesResponseType(404)]
public async Task<IActionResult> GetById(string id)
{
var appointment = await _service.GetByIdAsync(id, UserId());
return appointment is null ? NotFound() : Ok(appointment);
}
/// <summary>Book an appointment slot.</summary>
[HttpPost]
[ProducesResponseType(typeof(AppointmentDto), 201)]
[ProducesResponseType(typeof(ValidationProblemDetails), 422)]
[ProducesResponseType(409)] // slot taken
public async Task<IActionResult> Create([FromBody] BookAppointmentRequest request)
{
try
{
var appointment = await _service.BookAsync(request, UserId());
return CreatedAtAction(nameof(GetById),
new { id = appointment.Id }, appointment);
}
catch (SlotUnavailableException)
{
return Conflict(new { message = "Slot is no longer available" });
}
}
/// <summary>Cancel an appointment.</summary>
[HttpPatch("{id}")]
[ProducesResponseType(typeof(AppointmentDto), 200)]
[ProducesResponseType(404)]
public async Task<IActionResult> Update(string id, [FromBody] UpdateAppointmentRequest request)
{
var updated = await _service.UpdateAsync(id, request, UserId());
return updated is null ? NotFound() : Ok(updated);
}
[HttpDelete("{id}")]
[ProducesResponseType(204)]
[ProducesResponseType(404)]
public async Task<IActionResult> Delete(string id)
{
var deleted = await _service.DeleteAsync(id, UserId());
return deleted ? NoContent() : NotFound();
}
private string UserId() =>
User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? throw new UnauthorizedAccessException();
}REST in Python ā FastAPI
from fastapi import FastAPI, Depends, HTTPException, status
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
app = FastAPI(title="Appointment API", version="1.0.0")
class BookAppointmentRequest(BaseModel):
slot_id: str
reason: str
idempotency_key: str
class AppointmentResponse(BaseModel):
id: str
slot_id: str
patient_id: str
provider: str
date: datetime
status: str
confirmation_number: str
@app.get("/appointments", response_model=list[AppointmentResponse])
async def list_appointments(
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
current_user = Depends(get_current_user)
):
return await appointment_service.get_for_patient(current_user.id, from_date, to_date)
@app.post("/appointments", response_model=AppointmentResponse, status_code=201)
async def book_appointment(
request: BookAppointmentRequest,
current_user = Depends(get_current_user)
):
try:
return await appointment_service.book(request, current_user.id)
except SlotUnavailableError:
raise HTTPException(status_code=409, detail="Slot is no longer available")
@app.delete("/appointments/{appointment_id}", status_code=204)
async def cancel_appointment(
appointment_id: str,
current_user = Depends(get_current_user)
):
success = await appointment_service.cancel(appointment_id, current_user.id)
if not success:
raise HTTPException(status_code=404, detail="Appointment not found")HTTP Status Codes ā Use Them Correctly
| Code | Meaning | When to use | |------|---------|-------------| | 200 | OK | GET, PATCH, PUT succeeded | | 201 | Created | POST that created a resource | | 204 | No Content | DELETE succeeded | | 400 | Bad Request | Malformed JSON, missing required field | | 401 | Unauthorised | No token or invalid token | | 403 | Forbidden | Valid token, but no permission for this resource | | 404 | Not Found | Resource does not exist | | 409 | Conflict | Race condition, duplicate, state conflict | | 422 | Unprocessable Entity | Business rule violation, validation failure | | 429 | Too Many Requests | Rate limit exceeded | | 500 | Server Error | Unexpected failure ā do not leak details |
REST Versioning
URL path versioning (most common):
/api/v1/appointments
/api/v2/appointments
Header versioning (cleaner URLs, harder to test):
GET /appointments
Accept: application/vnd.mybcat.v2+json
Query parameter (least clean, avoid):
GET /appointments?version=2Real example ā Stripe:
Stripe uses date-based versioning: Stripe-Version: 2024-04-10. Each API key has a default version, and clients opt in to newer versions explicitly. Old versions are supported for years. This lets Stripe evolve the API without breaking existing integrations.
Minimal API ā Same REST, Less Ceremony
Minimal API (introduced in .NET 6) is not a different API style from REST. It is a different way to write REST endpoints in .NET ā without controllers, without attributes, without the MVC framework overhead.
Traditional Controller vs Minimal API
// Traditional Controller (MVC) ā 40+ lines for one endpoint
[ApiController]
[Route("api/[controller]")]
public class AppointmentsController : ControllerBase
{
private readonly IAppointmentService _service;
public AppointmentsController(IAppointmentService service)
{
_service = service;
}
[HttpGet("{id}")]
[ProducesResponseType(200)]
[ProducesResponseType(404)]
public async Task<IActionResult> GetById(string id)
{
var apt = await _service.GetByIdAsync(id);
return apt is null ? NotFound() : Ok(apt);
}
}
// Minimal API ā same endpoint, 4 lines
app.MapGet("/appointments/{id}", async (string id, IAppointmentService service) =>
{
var apt = await service.GetByIdAsync(id);
return apt is null ? Results.NotFound() : Results.Ok(apt);
});Minimal API ā Full Example
// Program.cs ā entire API in one file (small services)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IAppointmentService, AppointmentService>();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => { /* config */ });
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// Group related endpoints
var appointments = app.MapGroup("/api/v1/appointments")
.RequireAuthorization()
.WithTags("Appointments");
appointments.MapGet("/", async (
IAppointmentService service,
ClaimsPrincipal user,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to) =>
{
var userId = user.FindFirst(ClaimTypes.NameIdentifier)!.Value;
var results = await service.GetForPatientAsync(userId, from, to);
return Results.Ok(results);
})
.WithName("ListAppointments")
.Produces<List<AppointmentDto>>(200);
appointments.MapPost("/", async (
BookAppointmentRequest request,
IAppointmentService service,
ClaimsPrincipal user) =>
{
try
{
var userId = user.FindFirst(ClaimTypes.NameIdentifier)!.Value;
var apt = await service.BookAsync(request, userId);
return Results.Created($"/api/v1/appointments/{apt.Id}", apt);
}
catch (SlotUnavailableException)
{
return Results.Conflict(new { message = "Slot no longer available" });
}
})
.WithName("BookAppointment")
.Produces<AppointmentDto>(201)
.Produces(409);
appointments.MapDelete("/{id}", async (
string id,
IAppointmentService service,
ClaimsPrincipal user) =>
{
var userId = user.FindFirst(ClaimTypes.NameIdentifier)!.Value;
var deleted = await service.DeleteAsync(id, userId);
return deleted ? Results.NoContent() : Results.NotFound();
});
app.Run();When to Use Minimal API vs Controllers
| | Minimal API | Controller-Based MVC | |---|---|---| | Best for | Microservices, small APIs, Lambda functions | Large APIs, complex routing, teams familiar with MVC | | Code organisation | Route groups in Program.cs or extension methods | One class per resource | | Testing | Slightly harder (WebApplicationFactory) | Easy (constructor injection) | | Startup performance | Faster (less reflection) | Slightly slower | | Middleware pipeline | Same | Same | | Feature richness | Parity since .NET 8 | Marginally more features |
Real example ā .NET Lambda functions on AWS:
Minimal API is ideal for Lambda-hosted APIs. The cold start time is lower because less framework initialisation happens. A Lambda that handles /appointments endpoints is a perfect Minimal API candidate ā small surface area, single responsibility.
GraphQL ā Client-Defined Queries
REST gives you fixed endpoints. GraphQL gives clients a query language to request exactly what they need ā no more, no less.
The Problem GraphQL Solves
Mobile app dashboard ā REST approach:
Screen needs: patient name, next appointment date, unread messages count
GET /patients/me ā 40 fields (most unused on mobile)
GET /appointments?limit=1 ā 25 fields (most unused)
GET /messages/unread/count ā ok, this one is fine
3 requests, massive over-fetchingSame screen ā GraphQL approach:
query PatientDashboard {
me {
name # only this field from patient
}
nextAppointment {
date # only these two fields
providerName
}
unreadMessageCount # one number
}One request. Exactly the three fields needed. Nothing else.
GraphQL Schema
# schema.graphql ā the contract between client and server
type Query {
me: Patient
appointment(id: ID!): Appointment
appointments(from: String, to: String): [Appointment!]!
availableSlots(date: String!, practiceId: ID!): [Slot!]!
}
type Mutation {
bookAppointment(input: BookAppointmentInput!): BookAppointmentResult!
cancelAppointment(id: ID!): Boolean!
}
type Subscription {
appointmentStatusChanged(appointmentId: ID!): AppointmentStatusEvent!
}
type Patient {
id: ID!
name: String!
email: String!
phone: String
appointments: [Appointment!]!
}
type Appointment {
id: ID!
date: String!
time: String!
provider: Provider!
status: AppointmentStatus!
confirmationNumber: String!
}
type Provider {
id: ID!
name: String!
specialty: String!
}
enum AppointmentStatus {
CONFIRMED
CANCELLED
COMPLETED
NO_SHOW
}
input BookAppointmentInput {
slotId: ID!
reason: String!
idempotencyKey: String!
}
type BookAppointmentResult {
appointment: Appointment
error: String
}GraphQL Server in .NET ā Hot Chocolate
// Program.cs
builder.Services
.AddGraphQLServer()
.AddQueryType<Query>()
.AddMutationType<Mutation>()
.AddSubscriptionType<Subscription>()
.AddAuthorization()
.AddInMemorySubscriptions();
// Query resolver
public class Query
{
[Authorize]
public async Task<Patient?> Me(
[Service] IPatientService patientService,
ClaimsPrincipal claimsPrincipal)
{
var userId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return await patientService.GetByIdAsync(userId);
}
[Authorize]
public async Task<IEnumerable<Slot>> AvailableSlots(
string date,
string practiceId,
[Service] ISlotService slotService)
{
return await slotService.GetAvailableAsync(date, practiceId);
}
}
// Mutation resolver
public class Mutation
{
[Authorize]
public async Task<BookAppointmentResult> BookAppointment(
BookAppointmentInput input,
[Service] IAppointmentService appointmentService,
ClaimsPrincipal claimsPrincipal)
{
try
{
var userId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var appointment = await appointmentService.BookAsync(input, userId);
return new BookAppointmentResult { Appointment = appointment };
}
catch (SlotUnavailableException ex)
{
return new BookAppointmentResult { Error = ex.Message };
}
}
}GraphQL ā N+1 Problem and DataLoader
The biggest GraphQL pitfall: querying appointments with providers causes N+1 database calls.
# This query triggers N+1 database calls without DataLoader
query {
appointments { # 1 query for 100 appointments
provider { # 100 separate queries for each provider
name
specialty
}
}
}Fix with DataLoader (batching):
// DataLoader batches all provider lookups into one query
public class ProviderByIdDataLoader : BatchDataLoader<string, Provider>
{
private readonly IProviderService _providerService;
protected override async Task<IReadOnlyDictionary<string, Provider>> LoadBatchAsync(
IReadOnlyList<string> keys, CancellationToken ct)
{
// Called ONCE with all provider IDs ā one database query
var providers = await _providerService.GetByIdsAsync(keys);
return providers.ToDictionary(p => p.Id);
}
}
// Appointment type uses DataLoader
public class AppointmentType : ObjectType<Appointment>
{
protected override void Configure(IObjectTypeDescriptor<Appointment> descriptor)
{
descriptor
.Field(a => a.ProviderId)
.Name("provider")
.ResolveWith<AppointmentResolvers>(r => r.GetProviderAsync(default!, default!, default));
}
}
public class AppointmentResolvers
{
public async Task<Provider> GetProviderAsync(
[Parent] Appointment appointment,
ProviderByIdDataLoader dataLoader,
CancellationToken ct)
{
return await dataLoader.LoadAsync(appointment.ProviderId, ct);
// All appointments batch their provider IDs ā one query total
}
}When GraphQL is the Right Choice
Use GraphQL when:
- Mobile app needs different data shapes than web app from the same backend
- Frontend team iterates fast and doesn't want to wait for new REST endpoints
- Multiple clients (iOS, Android, web, partner APIs) each need different fields
- You have complex, interconnected data (social graphs, product catalogues)
Do NOT use GraphQL when:
- Simple CRUD with predictable data shapes
- File uploads (REST is better)
- Caching is critical (GraphQL POST requests are not cached by browsers)
- Your team is small and the GraphQL learning curve isn't worth it
- You need simple, public APIs (REST is more universally understood)
Real example ā GitHub API v4: GitHub moved from REST (v3) to GraphQL (v4) because clients were making dozens of REST calls to get data for a single page. The GitHub CLI fetches PR details, comments, checks, and files in one GraphQL query instead of 6 REST calls.
Real example ā Shopify Storefront API: Shopify's storefront API is GraphQL. A mobile app product page needs product title, price, images (only first 3), variants, and reviews. With REST, that's 3ā4 calls and massive over-fetching. With GraphQL, one query, exactly the right fields.
gRPC ā Binary, Fast, Strongly Typed
gRPC uses HTTP/2, Protocol Buffers (binary serialisation), and generates clients automatically in any language. It is 5ā10x faster than JSON REST for the same payload.
When REST Feels Wrong ā Service-to-Service Calls
REST JSON between internal services:
Request: HTTP/1.1 POST + JSON string (text) ā parse ā object
Response: object ā JSON string ā HTTP response
gRPC between internal services:
Request: HTTP/2 + Protocol Buffer (binary) ā deserialise ā struct
Response: struct ā binary ā HTTP/2 response
gRPC is:
- ~5-10x smaller payload (binary vs text)
- ~3-8x faster serialisation
- Strongly typed (schema enforced at compile time)
- Streaming built in (client, server, or bidirectional)Proto File ā The Contract
// appointment.proto
syntax = "proto3";
package appointments.v1;
service AppointmentService {
rpc GetAppointment (GetAppointmentRequest) returns (Appointment);
rpc ListAppointments (ListAppointmentsRequest) returns (ListAppointmentsResponse);
rpc BookAppointment (BookAppointmentRequest) returns (BookAppointmentResponse);
// Server-streaming: stream appointment status updates to client
rpc WatchAppointmentStatus (WatchRequest) returns (stream AppointmentStatusEvent);
}
message GetAppointmentRequest {
string appointment_id = 1;
string patient_id = 2;
}
message Appointment {
string id = 1;
string patient_id = 2;
string provider_id = 3;
string date = 4;
string time = 5;
AppointmentStatus status = 6;
string confirmation_number = 7;
}
enum AppointmentStatus {
APPOINTMENT_STATUS_UNSPECIFIED = 0;
APPOINTMENT_STATUS_CONFIRMED = 1;
APPOINTMENT_STATUS_CANCELLED = 2;
APPOINTMENT_STATUS_COMPLETED = 3;
}
message BookAppointmentRequest {
string slot_id = 1;
string patient_id = 2;
string reason = 3;
string idempotency_key = 4;
}
message BookAppointmentResponse {
oneof result {
Appointment appointment = 1;
string error = 2;
}
}
message AppointmentStatusEvent {
string appointment_id = 1;
AppointmentStatus new_status = 2;
string changed_at = 3;
}gRPC Server in .NET
// AppointmentGrpcService.cs
public class AppointmentGrpcService : AppointmentService.AppointmentServiceBase
{
private readonly IAppointmentRepository _repo;
private readonly ILogger<AppointmentGrpcService> _logger;
public override async Task<Appointment> GetAppointment(
GetAppointmentRequest request,
ServerCallContext context)
{
var apt = await _repo.GetByIdAsync(request.AppointmentId);
if (apt is null)
throw new RpcException(new Status(StatusCode.NotFound,
$"Appointment {request.AppointmentId} not found"));
if (apt.PatientId != request.PatientId)
throw new RpcException(new Status(StatusCode.PermissionDenied,
"Not authorised to view this appointment"));
return apt.ToProto();
}
public override async Task WatchAppointmentStatus(
WatchRequest request,
IServerStreamWriter<AppointmentStatusEvent> responseStream,
ServerCallContext context)
{
// Stream status events until client disconnects
await foreach (var statusEvent in _repo.WatchStatusChangesAsync(
request.AppointmentId, context.CancellationToken))
{
await responseStream.WriteAsync(new AppointmentStatusEvent
{
AppointmentId = statusEvent.AppointmentId,
NewStatus = statusEvent.Status.ToProto(),
ChangedAt = statusEvent.ChangedAt.ToString("O")
});
}
}
}
// Register in Program.cs
app.MapGrpcService<AppointmentGrpcService>();gRPC Client (calling from another service)
// Another microservice consuming the appointment gRPC service
var channel = GrpcChannel.ForAddress("https://appointment-service:5001");
var client = new AppointmentService.AppointmentServiceClient(channel);
// Simple call
var appointment = await client.GetAppointmentAsync(new GetAppointmentRequest
{
AppointmentId = "apt_123",
PatientId = "pat_456"
});
// Server-streaming ā receive a stream of events
using var call = client.WatchAppointmentStatus(new WatchRequest
{
AppointmentId = "apt_123"
});
await foreach (var statusEvent in call.ResponseStream.ReadAllAsync())
{
Console.WriteLine($"Status changed to: {statusEvent.NewStatus} at {statusEvent.ChangedAt}");
}gRPC in Python
# Generated from proto by grpc_tools.protoc
import grpc
import appointment_pb2
import appointment_pb2_grpc
# Client
channel = grpc.secure_channel('appointment-service:443', grpc.ssl_channel_credentials())
stub = appointment_pb2_grpc.AppointmentServiceStub(channel)
# Simple call
response = stub.GetAppointment(appointment_pb2.GetAppointmentRequest(
appointment_id='apt_123',
patient_id='pat_456'
))
print(f"Appointment: {response.confirmation_number}")
# Server streaming
for event in stub.WatchAppointmentStatus(appointment_pb2.WatchRequest(
appointment_id='apt_123'
)):
print(f"Status: {event.new_status} at {event.changed_at}")When to Use gRPC
Use gRPC when:
- Service-to-service communication inside your infrastructure
- Performance is critical (payment processing, real-time data pipelines)
- You want strongly typed contracts enforced at compile time
- You need streaming (live data, file transfer, bidirectional chat between services)
- Polyglot microservices (client generated in Go, Python, Java, .NET all from one proto)
Do NOT use gRPC when:
- Public-facing APIs (browsers cannot call gRPC directly without grpc-web proxy)
- Simple CRUD that doesn't need the complexity
- Third-party integrations (REST is universally understood)
- You need human-readable requests for debugging
Real example ā Google internal APIs: gRPC was built at Google. Every internal service at Google communicates over gRPC. Stubby (Google's internal predecessor to gRPC) has handled trillions of internal calls per day for over a decade.
Real example ā Netflix: Netflix uses gRPC for service-to-service calls. Their content metadata service serves hundreds of internal services that need fast, typed access to show/movie data. REST would add too much serialisation overhead at that scale.
WebSocket ā Real-Time Bidirectional Communication
HTTP is request/response ā the client asks, the server answers. WebSocket is a persistent connection over which both sides can send data at any time, independently.
HTTP (request/response):
Client āāāā GET /messages āāāāāŗ Server
Client āāāā 200 [messages] āāā Server
Connection closed.
WebSocket (persistent connection):
Client āāāā upgrade request āāāŗ Server
Client āāāāā upgrade OK āāāāāāā Server
=== connection is now open ===
Client āāāā "typing..." āāāāāāāŗ Server (anytime)
Server āāāā "new message" āāāā Server (anytime)
Server āāāā "user joined" āāāāāŗ Client (anytime)
=== connection stays open until either side closes it ===WebSocket in .NET ā Minimal API
// Program.cs
app.UseWebSockets();
app.Map("/ws/appointments/{patientId}", async (HttpContext context, string patientId) =>
{
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = 400;
return;
}
using var ws = await context.WebSockets.AcceptWebSocketAsync();
var connectionId = Guid.NewGuid().ToString();
// Register this connection
_connectionManager.Add(patientId, connectionId, ws);
try
{
var buffer = new byte[4096];
while (ws.State == WebSocketState.Open)
{
var result = await ws.ReceiveAsync(buffer, CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close)
break;
var message = JsonSerializer.Deserialize<WsMessage>(
buffer.AsMemory(0, result.Count));
await HandleClientMessage(message, patientId, ws);
}
}
finally
{
_connectionManager.Remove(connectionId);
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", default);
}
});
// Push to connected patient from anywhere in the app
public async Task PushAppointmentUpdate(string patientId, AppointmentUpdate update)
{
var connections = _connectionManager.GetConnections(patientId);
var payload = JsonSerializer.SerializeToUtf8Bytes(update);
foreach (var (connectionId, ws) in connections)
{
if (ws.State == WebSocketState.Open)
{
await ws.SendAsync(payload, WebSocketMessageType.Text, true, default);
}
}
}WebSocket in TypeScript ā Client
// React hook for WebSocket connection
function useAppointmentUpdates(patientId: string) {
const [updates, setUpdates] = useState<AppointmentUpdate[]>([]);
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
const token = getAuthToken();
const ws = new WebSocket(`wss://api.mybcat.com/ws/appointments/${patientId}?token=${token}`);
wsRef.current = ws;
ws.onopen = () => {
console.log('Connected to appointment updates');
};
ws.onmessage = (event) => {
const update: AppointmentUpdate = JSON.parse(event.data);
setUpdates(prev => [...prev, update]);
};
ws.onclose = (event) => {
if (!event.wasClean) {
// Reconnect with exponential backoff
setTimeout(() => reconnect(), 2000);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
return () => ws.close();
}, [patientId]);
return updates;
}SignalR ā WebSocket with Fallbacks (.NET)
SignalR is the .NET abstraction over WebSockets. If WebSocket is not available (old browser, restrictive proxy), it falls back to Server-Sent Events, then long-polling. You write one API and it works everywhere.
// Hub definition
public class AppointmentHub : Hub
{
// Client joins a group for their patient ID
public async Task JoinPatientGroup(string patientId)
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"patient-{patientId}");
}
// Client can send messages too (bidirectional)
public async Task NotifyTyping(string appointmentId)
{
await Clients.Group($"appointment-{appointmentId}")
.SendAsync("UserTyping", Context.ConnectionId);
}
}
// Push from a background service to all connected patients
public class AppointmentUpdateNotifier
{
private readonly IHubContext<AppointmentHub> _hubContext;
public async Task NotifyPatientAsync(string patientId, AppointmentUpdate update)
{
await _hubContext.Clients
.Group($"patient-{patientId}")
.SendAsync("AppointmentUpdated", update);
}
}
// Register
app.MapHub<AppointmentHub>("/hubs/appointments");// TypeScript client
import * as signalR from '@microsoft/signalr';
const connection = new signalR.HubConnectionBuilder()
.withUrl('/hubs/appointments', {
accessTokenFactory: () => getAuthToken()
})
.withAutomaticReconnect([0, 2000, 10000, 30000]) // retry intervals
.build();
connection.on('AppointmentUpdated', (update: AppointmentUpdate) => {
// React to appointment status change in real-time
updateAppointmentInState(update);
});
await connection.start();
await connection.invoke('JoinPatientGroup', patientId);Real example ā Slack: Slack uses WebSockets for the real-time message delivery. When you type a message, it goes over WebSocket. When someone sends you a message, it arrives over the persistent WebSocket connection. The connection is established when you open Slack and stays open while the app is running.
Real example ā Uber driver app: Driver location updates every second go over WebSockets from the driver's phone to Uber's servers. Rider-facing ETA updates come back to the rider's phone over WebSockets. No polling ā the connection is always open.
Server-Sent Events (SSE) ā One-Way Server Push
SSE is simpler than WebSocket ā the server pushes events to the client over a standard HTTP connection. The client cannot send data back (it uses a separate REST call for that).
WebSocket: Client āāāāā data āāāāāāŗ Server (bidirectional)
SSE: Client āāāāā events āāāā Server (server ā client only)SSE is perfect for dashboards, live feeds, and notification streams where the client only needs to receive updates.
// .NET SSE endpoint
app.MapGet("/api/v1/appointments/{id}/status-stream", async (
string id,
HttpContext context,
IAppointmentService service) =>
{
context.Response.Headers["Content-Type"] = "text/event-stream";
context.Response.Headers["Cache-Control"] = "no-cache";
context.Response.Headers["X-Accel-Buffering"] = "no"; // disable nginx buffering
await foreach (var update in service.StreamStatusUpdatesAsync(
id, context.RequestAborted))
{
await context.Response.WriteAsync($"event: statusChanged\n");
await context.Response.WriteAsync($"data: {JsonSerializer.Serialize(update)}\n\n");
await context.Response.Body.FlushAsync();
}
});// Browser client
const eventSource = new EventSource(`/api/v1/appointments/${appointmentId}/status-stream`, {
withCredentials: true
});
eventSource.addEventListener('statusChanged', (event) => {
const update = JSON.parse(event.data);
setAppointmentStatus(update.status);
});
eventSource.onerror = () => {
// Browser auto-reconnects on error
console.log('Connection lost, browser will reconnect...');
};Real example ā GitHub Actions: When you watch a GitHub Actions workflow run, the log lines stream to your browser in real-time. This is SSE ā the server streams log lines as they are generated. The browser does not need to send anything back, so SSE is perfect.
Real example ā Twitter/X timeline: The "new tweets" notification that appears at the top of the Twitter feed is SSE. Twitter's server pushes a notification when new tweets are available. Clicking loads them via a separate REST call.
Webhooks ā Server Pushes to Your Endpoint
A webhook is the reverse of an API call: instead of your app polling "did anything change?", the external service calls YOUR endpoint when something happens.
REST (polling):
Your app āāāā GET /payment/status āāāāāŗ Stripe (every 5 seconds)
Your app āāāā GET /payment/status āāāāāŗ Stripe (every 5 seconds)
Your app āāāā GET /payment/status āāāāāŗ Stripe (every 5 seconds)
Stripe āāāā 200 { status: pending } āāāāāāāāāāāā (x100 times)
Stripe āāāā 200 { status: paid } āāāāāāāāāāāā (finally)
Webhook (event-driven):
Stripe āāāā POST /webhooks/stripe { status: paid } āāāŗ Your app
(Stripe calls you exactly once when it happens ā no polling)Secure Webhook Receiver
import hmac
import hashlib
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
STRIPE_WEBHOOK_SECRET = os.environ['STRIPE_WEBHOOK_SECRET']
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
payload = await request.body()
sig_header = request.headers.get('Stripe-Signature')
# 1. Verify signature ā reject tampered webhooks
if not verify_stripe_signature(payload, sig_header, STRIPE_WEBHOOK_SECRET):
raise HTTPException(status_code=400, detail="Invalid signature")
event = json.loads(payload)
# 2. Return 200 immediately ā do heavy work async
# Stripe will retry if you don't respond within 30 seconds
background_tasks.add_task(handle_stripe_event, event)
return {"received": True}
def verify_stripe_signature(payload: bytes, sig_header: str, secret: str) -> bool:
timestamp = sig_header.split(',')[0].split('=')[1]
expected_sig = hmac.new(
secret.encode(),
f"{timestamp}.{payload.decode()}".encode(),
hashlib.sha256
).hexdigest()
received_sig = sig_header.split('v1=')[1]
return hmac.compare_digest(expected_sig, received_sig)
async def handle_stripe_event(event: dict):
if event['type'] == 'payment_intent.succeeded':
payment = event['data']['object']
await mark_appointment_paid(payment['metadata']['appointment_id'])
elif event['type'] == 'payment_intent.payment_failed':
payment = event['data']['object']
await handle_payment_failure(payment['metadata']['appointment_id'])Webhook Idempotency
Webhook providers retry on failure. Your endpoint must handle duplicates.
processed_events = set() # use Redis in production
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
event = json.loads(await request.body())
event_id = event['id']
# Idempotency ā Stripe retries with same event ID
if event_id in processed_events:
return {"received": True} # already handled
processed_events.add(event_id)
background_tasks.add_task(handle_stripe_event, event)
return {"received": True}Real examples of webhooks:
- Stripe: payment_intent.succeeded, customer.subscription.deleted
- GitHub: push, pull_request.opened, workflow_run.completed
- Twilio: message delivery status (delivered, failed, undelivered)
- Shopify: order/created, inventory_item/updated
- AWS SNS: HTTP/HTTPS subscription delivers to your webhook endpoint
Complete Comparison: When to Use What
| | REST | Minimal API | GraphQL | gRPC | WebSocket | SSE | Webhook | |---|---|---|---|---|---|---|---| | Communication | Request/Response | Request/Response | Request/Response | Request/Response + Streaming | Bidirectional | Server ā Client | Server ā Your server | | Best for | Public APIs, CRUD | Microservices, Lambda | Mobile apps, complex data | Internal services, streaming | Chat, live updates | Dashboards, feeds | External event notifications | | Performance | Good | Good | Good | Excellent | Excellent | Good | N/A | | Caching | Yes (GET) | Yes (GET) | Limited (POST) | Limited | No | No | No | | Browser support | Yes | Yes | Yes | No (needs proxy) | Yes | Yes | N/A (server-side) | | Type safety | Weak (OpenAPI) | Weak (OpenAPI) | Schema | Excellent (proto) | Manual | Manual | Manual | | Streaming | No | No | Subscriptions | Yes (built-in) | Yes | Yes (one-way) | No | | Learning curve | Low | Low | Medium | High | Medium | Low | Low |
Decision Flow
Is the client a browser or mobile app calling your API?
āā Yes, is data shape variable per client/screen?
āā Yes ā GraphQL
āā No ā REST or Minimal API
Is this internal service-to-service?
āā Yes, needs high performance / streaming / strong types?
āā Yes ā gRPC
āā No ā REST is fine
Does the client need to receive data without asking for it?
āā Yes, bidirectional (client also sends)?
āā Yes ā WebSocket / SignalR
āā No ā SSE (simpler, browser reconnects automatically)
Does an external service need to notify you when something happens?
āā Yes ā Webhook receiver
Is this a small .NET microservice or Lambda function?
āā Yes ā Minimal API (less overhead than MVC controllers)
Is this a large .NET API with many resources and a big team?
āā Yes ā Controller-based MVC RESTInterview Answers
"What is the difference between REST and GraphQL?"
"REST gives you fixed endpoints ā each endpoint returns a predetermined shape. GraphQL gives the client a query language to request exactly the fields it needs from a single endpoint. REST over-fetches when the client needs only part of the response, and under-fetches when one screen needs data from multiple resources, requiring multiple calls. GraphQL solves both: one query, exactly the right shape. The tradeoff is complexity ā GraphQL requires schema design, resolver implementation, and protection against expensive queries. For simple CRUD, REST is always simpler. GraphQL earns its complexity when multiple clients need different shapes from the same backend, or when mobile performance is critical."
"When would you use gRPC instead of REST?"
"gRPC for service-to-service communication inside the infrastructure, REST for public-facing APIs. gRPC uses HTTP/2 and Protocol Buffers ā binary serialisation that is 5ā10x faster than JSON. The schema defined in the proto file generates clients in any language automatically, giving you compile-time type safety across service boundaries. The reason you don't use gRPC for public APIs is that browsers cannot call gRPC directly ā they need a gRPC-Web proxy. For internal microservices that exchange millions of messages per second, gRPC's performance advantage is significant."
"How is WebSocket different from polling?"
"Polling means the client repeatedly asks 'is there anything new?' ā every few seconds, whether there is new data or not. This wastes bandwidth and adds latency (you only learn about events after the next poll interval). WebSocket is a persistent connection where the server pushes data the moment it is available. For a patient waiting to see if their appointment is confirmed, polling checks every 5 seconds and might show them the confirmation up to 5 seconds late. WebSocket delivers it the instant the status changes, with no extra requests."
REST API Knowledge Check
5 questions Ā· Test what you just learned Ā· Instant explanations
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.