Back to blog
Backend Systemsintermediate

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.

SystemForgeApril 21, 202624 min read
REST APIMinimal APIGraphQLgRPCWebSocketSignalRWebhookAPI Design.NETTypeScriptSystem DesignInterview Prep
Share:š•

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 guide

REST 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 123

3. 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 notification

Nouns, not verbs. Resources, not actions.

REST in .NET — Full Controller Pattern

C#
[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

Python
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=2

Real 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

C#
// 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

C#
// 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-fetching

Same screen — GraphQL approach:

GRAPHQL
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

GRAPHQL
# 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

C#
// 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.

GRAPHQL
# 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):

C#
// 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

PROTOBUF
// 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

C#
// 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)

C#
// 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

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

C#
// 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

TYPESCRIPT
// 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.

C#
// 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
// 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.

C#
// .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();
    }
});
TYPESCRIPT
// 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

Python
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.

Python
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 REST

Interview 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?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.