Traditional request-response APIs require polling for updates. Users refresh the page. Clients check every few seconds. It's inefficient and creates a poor user experience.

WitRPC's event system enables true real-time updates. When something happens on the server, clients are notified instantly. No polling. No delays. Just immediate, push-based updates.

Today, we'll build a real-time dashboard that displays live data using WitRPC events.

The Power of Server-to-Client Events

In WitRPC, events are first-class citizens. Define them in your interface, raise them on the server, and handle them on the client, just like local .NET events:

csharp
// Shared contract
public interface IStockService
{
    Task<List<Stock>> GetAllStocksAsync();
    
    // Events pushed from server to client
    event Action<StockPriceUpdate> PriceChanged;
    event Action<TradeExecuted> TradeExecuted;
    event Action<MarketAlert> AlertTriggered;
}

When the server raises PriceChanged, every subscribed client receives the update immediately. No HTTP requests. No WebSocket message parsing. Just a clean event handler:

csharp
var stockService = client.GetService<IStockService>();

stockService.PriceChanged += update =>
{
    Console.WriteLine($"{update.Symbol}: ${update.Price:F2} ({update.ChangePercent:+0.00;-0.00}%)");
};

This is the foundation for real-time dashboards.

Architecture Overview

Building the Service Contract

Define Events with Meaningful Data

csharp
// Contracts/IMarketService.cs
public interface IMarketService
{
    // Request/Response methods
    Task<MarketSummary> GetMarketSummaryAsync();
    Task<List<Stock>> GetWatchlistAsync(string userId);
    Task<StockDetails> GetStockDetailsAsync(string symbol);
    Task AddToWatchlistAsync(string userId, string symbol);
    
    // Real-time events
    event Action<StockPriceUpdate> PriceUpdated;
    event Action<VolumeSpike> VolumeAlert;
    event Action<MarketStatus> MarketStatusChanged;
    event Action<NewsItem> BreakingNews;
}

// Contracts/IOrderService.cs  
public interface IOrderService
{
    Task<Order> PlaceOrderAsync(OrderRequest request);
    Task<Order> GetOrderAsync(string orderId);
    Task<List<Order>> GetOpenOrdersAsync(string userId);
    Task CancelOrderAsync(string orderId);
    
    // Real-time events
    event Action<Order> OrderPlaced;
    event Action<Order> OrderFilled;
    event Action<Order> OrderCancelled;
    event Action<OrderError> OrderFailed;
}

// Contracts/IAlertService.cs
public interface IAlertService
{
    Task<List<Alert>> GetActiveAlertsAsync();
    Task CreateAlertAsync(AlertDefinition definition);
    Task DeleteAlertAsync(string alertId);
    
    // Real-time events
    event Action<Alert> AlertTriggered;
    event Action<Alert> AlertCleared;
}

Define Event Data Models

csharp
// Models/StockPriceUpdate.cs
public class StockPriceUpdate
{
    public string Symbol { get; set; }
    public decimal Price { get; set; }
    public decimal PreviousPrice { get; set; }
    public decimal ChangePercent => PreviousPrice != 0 
        ? ((Price - PreviousPrice) / PreviousPrice) * 100 
        : 0;
    public long Volume { get; set; }
    public DateTime Timestamp { get; set; }
}

// Models/VolumeSpike.cs
public class VolumeSpike
{
    public string Symbol { get; set; }
    public long CurrentVolume { get; set; }
    public long AverageVolume { get; set; }
    public double SpikeMultiplier => AverageVolume > 0 
        ? (double)CurrentVolume / AverageVolume 
        : 0;
    public DateTime Timestamp { get; set; }
}

// Models/MarketStatus.cs
public class MarketStatus
{
    public string Market { get; set; }  // NYSE, NASDAQ, etc.
    public MarketState State { get; set; }  // PreMarket, Open, Closed, AfterHours
    public DateTime? NextStateChange { get; set; }
    public string Message { get; set; }
}

public enum MarketState
{
    PreMarket,
    Open,
    Closed,
    AfterHours,
    Holiday
}

Server Implementation

Market Service with Real-Time Updates

csharp
public class MarketService : IMarketService
{
    // Events initialized with empty delegates
    public event Action<StockPriceUpdate> PriceUpdated = delegate { };
    public event Action<VolumeSpike> VolumeAlert = delegate { };
    public event Action<MarketStatus> MarketStatusChanged = delegate { };
    public event Action<NewsItem> BreakingNews = delegate { };
    
    private readonly IMarketDataFeed _marketFeed;
    private readonly ILogger<MarketService> _logger;
    private readonly ConcurrentDictionary<string, decimal> _lastPrices = new();
    
    public MarketService(IMarketDataFeed marketFeed, ILogger<MarketService> logger)
    {
        _marketFeed = marketFeed;
        _logger = logger;
        
        // Subscribe to market data feed
        _marketFeed.OnTick += HandleMarketTick;
        _marketFeed.OnNews += HandleNews;
    }
    
    public async Task<MarketSummary> GetMarketSummaryAsync()
    {
        return await _marketFeed.GetSummaryAsync();
    }
    
    public async Task<List<Stock>> GetWatchlistAsync(string userId)
    {
        // Return current prices for user's watchlist
        var symbols = await _watchlistRepo.GetSymbolsAsync(userId);
        return await _marketFeed.GetQuotesAsync(symbols);
    }
    
    public async Task<StockDetails> GetStockDetailsAsync(string symbol)
    {
        return await _marketFeed.GetDetailsAsync(symbol);
    }
    
    // Handle incoming market data
    private void HandleMarketTick(MarketTick tick)
    {
        // Get previous price
        _lastPrices.TryGetValue(tick.Symbol, out var previousPrice);
        
        // Update stored price
        _lastPrices[tick.Symbol] = tick.Price;
        
        // Create update event
        var update = new StockPriceUpdate
        {
            Symbol = tick.Symbol,
            Price = tick.Price,
            PreviousPrice = previousPrice,
            Volume = tick.Volume,
            Timestamp = tick.Timestamp
        };
        
        // Push to all subscribed clients
        PriceUpdated(update);
        
        // Check for volume spike
        if (tick.Volume > tick.AverageVolume * 2)
        {
            VolumeAlert(new VolumeSpike
            {
                Symbol = tick.Symbol,
                CurrentVolume = tick.Volume,
                AverageVolume = tick.AverageVolume,
                Timestamp = tick.Timestamp
            });
        }
        
        _logger.LogDebug("Price update: {Symbol} = {Price}", tick.Symbol, tick.Price);
    }
    
    private void HandleNews(NewsItem news)
    {
        if (news.IsBreaking)
        {
            BreakingNews(news);
            _logger.LogInformation("Breaking news: {Headline}", news.Headline);
        }
    }
    
    // Called by market status monitor
    public void UpdateMarketStatus(MarketStatus status)
    {
        MarketStatusChanged(status);
        _logger.LogInformation("Market status: {Market} is now {State}", 
            status.Market, status.State);
    }
}

Order Service with Trade Notifications

csharp
public class OrderService : IOrderService
{
    public event Action<Order> OrderPlaced = delegate { };
    public event Action<Order> OrderFilled = delegate { };
    public event Action<Order> OrderCancelled = delegate { };
    public event Action<OrderError> OrderFailed = delegate { };
    
    private readonly IOrderRepository _repository;
    private readonly ITradingEngine _tradingEngine;
    private readonly ILogger<OrderService> _logger;
    
    public OrderService(
        IOrderRepository repository,
        ITradingEngine tradingEngine,
        ILogger<OrderService> logger)
    {
        _repository = repository;
        _tradingEngine = tradingEngine;
        _logger = logger;
        
        // Subscribe to trading engine events
        _tradingEngine.OnFill += HandleOrderFill;
        _tradingEngine.OnReject += HandleOrderReject;
    }
    
    public async Task<Order> PlaceOrderAsync(OrderRequest request)
    {
        _logger.LogInformation(
            "Placing order: {Side} {Quantity} {Symbol} @ {Price}",
            request.Side, request.Quantity, request.Symbol, request.Price);
        
        var order = new Order
        {
            Id = Guid.NewGuid().ToString(),
            Symbol = request.Symbol,
            Side = request.Side,
            Quantity = request.Quantity,
            Price = request.Price,
            Status = OrderStatus.Pending,
            CreatedAt = DateTime.UtcNow
        };
        
        await _repository.SaveAsync(order);
        
        // Submit to trading engine
        await _tradingEngine.SubmitAsync(order);
        
        // Notify clients
        OrderPlaced(order);
        
        return order;
    }
    
    public async Task CancelOrderAsync(string orderId)
    {
        var order = await _repository.GetAsync(orderId);
        if (order == null) throw new NotFoundException($"Order {orderId} not found");
        
        order.Status = OrderStatus.Cancelled;
        order.UpdatedAt = DateTime.UtcNow;
        
        await _repository.SaveAsync(order);
        await _tradingEngine.CancelAsync(orderId);
        
        // Notify clients
        OrderCancelled(order);
        
        _logger.LogInformation("Order {OrderId} cancelled", orderId);
    }
    
    private void HandleOrderFill(string orderId, decimal fillPrice, int fillQuantity)
    {
        Task.Run(async () =>
        {
            var order = await _repository.GetAsync(orderId);
            if (order == null) return;
            
            order.FilledQuantity += fillQuantity;
            order.AveragePrice = fillPrice;
            order.Status = order.FilledQuantity >= order.Quantity 
                ? OrderStatus.Filled 
                : OrderStatus.PartiallyFilled;
            order.UpdatedAt = DateTime.UtcNow;
            
            await _repository.SaveAsync(order);
            
            // Notify clients
            OrderFilled(order);
            
            _logger.LogInformation(
                "Order {OrderId} filled: {Quantity} @ {Price}",
                orderId, fillQuantity, fillPrice);
        });
    }
    
    private void HandleOrderReject(string orderId, string reason)
    {
        Task.Run(async () =>
        {
            var order = await _repository.GetAsync(orderId);
            if (order == null) return;
            
            order.Status = OrderStatus.Rejected;
            order.RejectReason = reason;
            order.UpdatedAt = DateTime.UtcNow;
            
            await _repository.SaveAsync(order);
            
            // Notify clients
            OrderFailed(new OrderError
            {
                OrderId = orderId,
                Reason = reason,
                Timestamp = DateTime.UtcNow
            });
            
            _logger.LogWarning("Order {OrderId} rejected: {Reason}", orderId, reason);
        });
    }
}

Client Implementation

Dashboard Service Client

csharp
public class DashboardClient : IDisposable
{
    private readonly IWitClient _client;
    private IMarketService _marketService;
    private IOrderService _orderService;
    private IAlertService _alertService;
    
    public event Action<StockPriceUpdate> OnPriceUpdate;
    public event Action<Order> OnOrderUpdate;
    public event Action<Alert> OnAlert;
    public event Action<string> OnConnectionStatusChanged;
    
    public DashboardClient(string serverAddress, int port)
    {
        _client = WitClientBuilder.Build(options =>
        {
            options.WithTcp(serverAddress, port);
            options.WithMessagePack();
            options.WithEncryption();
            options.WithAutoReconnect(reconnect =>
            {
                reconnect.MaxAttempts = 0;  // Unlimited
                reconnect.OnReconnecting = (attempt, delay) =>
                    OnConnectionStatusChanged?.Invoke($"Reconnecting (attempt {attempt})...");
                reconnect.OnReconnected = () =>
                {
                    OnConnectionStatusChanged?.Invoke("Connected");
                    ResubscribeToEvents();
                };
                reconnect.OnReconnectionFailed = () =>
                    OnConnectionStatusChanged?.Invoke("Connection failed");
            });
        });
    }
    
    public async Task ConnectAsync()
    {
        await _client.ConnectAsync(TimeSpan.FromSeconds(10));
        
        // Get service proxies
        _marketService = _client.GetService<IMarketService>();
        _orderService = _client.GetService<IOrderService>();
        _alertService = _client.GetService<IAlertService>();
        
        // Subscribe to events
        SubscribeToEvents();
        
        OnConnectionStatusChanged?.Invoke("Connected");
    }
    
    private void SubscribeToEvents()
    {
        // Market events
        _marketService.PriceUpdated += HandlePriceUpdate;
        _marketService.VolumeAlert += HandleVolumeAlert;
        _marketService.MarketStatusChanged += HandleMarketStatus;
        _marketService.BreakingNews += HandleNews;
        
        // Order events
        _orderService.OrderPlaced += HandleOrderUpdate;
        _orderService.OrderFilled += HandleOrderUpdate;
        _orderService.OrderCancelled += HandleOrderUpdate;
        _orderService.OrderFailed += HandleOrderError;
        
        // Alert events
        _alertService.AlertTriggered += HandleAlert;
    }
    
    private void ResubscribeToEvents()
    {
        // Get fresh proxies after reconnection
        _marketService = _client.GetService<IMarketService>();
        _orderService = _client.GetService<IOrderService>();
        _alertService = _client.GetService<IAlertService>();
        
        SubscribeToEvents();
    }
    
    private void HandlePriceUpdate(StockPriceUpdate update)
    {
        OnPriceUpdate?.Invoke(update);
    }
    
    private void HandleVolumeAlert(VolumeSpike spike)
    {
        OnAlert?.Invoke(new Alert
        {
            Type = AlertType.VolumeSpike,
            Symbol = spike.Symbol,
            Message = $"Volume spike: {spike.SpikeMultiplier:F1}x average",
            Timestamp = spike.Timestamp
        });
    }
    
    private void HandleMarketStatus(MarketStatus status)
    {
        OnAlert?.Invoke(new Alert
        {
            Type = AlertType.MarketStatus,
            Message = $"{status.Market}: {status.State}",
            Timestamp = DateTime.UtcNow
        });
    }
    
    private void HandleNews(NewsItem news)
    {
        OnAlert?.Invoke(new Alert
        {
            Type = AlertType.BreakingNews,
            Symbol = news.Symbol,
            Message = news.Headline,
            Timestamp = news.PublishedAt
        });
    }
    
    private void HandleOrderUpdate(Order order)
    {
        OnOrderUpdate?.Invoke(order);
    }
    
    private void HandleOrderError(OrderError error)
    {
        OnAlert?.Invoke(new Alert
        {
            Type = AlertType.OrderError,
            Message = $"Order {error.OrderId} failed: {error.Reason}",
            Timestamp = error.Timestamp
        });
    }
    
    private void HandleAlert(Alert alert)
    {
        OnAlert?.Invoke(alert);
    }
    
    // Expose service methods
    public Task<MarketSummary> GetMarketSummaryAsync() 
        => _marketService.GetMarketSummaryAsync();
    
    public Task<Order> PlaceOrderAsync(OrderRequest request)
        => _orderService.PlaceOrderAsync(request);
    
    public void Dispose()
    {
        _client?.Dispose();
    }
}

WPF Dashboard ViewModel

csharp
public class DashboardViewModel : INotifyPropertyChanged, IDisposable
{
    private readonly DashboardClient _client;
    private readonly SynchronizationContext _uiContext;
    
    public ObservableCollection<StockViewModel> Stocks { get; } = new();
    public ObservableCollection<OrderViewModel> RecentOrders { get; } = new();
    public ObservableCollection<AlertViewModel> Alerts { get; } = new();
    
    private string _connectionStatus = "Disconnected";
    public string ConnectionStatus
    {
        get => _connectionStatus;
        set { _connectionStatus = value; OnPropertyChanged(); }
    }
    
    public DashboardViewModel()
    {
        _uiContext = SynchronizationContext.Current;
        _client = new DashboardClient("localhost", 5000);
        
        // Subscribe to client events
        _client.OnPriceUpdate += HandlePriceUpdate;
        _client.OnOrderUpdate += HandleOrderUpdate;
        _client.OnAlert += HandleAlert;
        _client.OnConnectionStatusChanged += status => 
            RunOnUI(() => ConnectionStatus = status);
    }
    
    public async Task InitializeAsync()
    {
        try
        {
            await _client.ConnectAsync();
            
            // Load initial data
            var summary = await _client.GetMarketSummaryAsync();
            RunOnUI(() =>
            {
                foreach (var stock in summary.TopStocks)
                {
                    Stocks.Add(new StockViewModel(stock));
                }
            });
        }
        catch (Exception ex)
        {
            ConnectionStatus = $"Error: {ex.Message}";
        }
    }
    
    private void HandlePriceUpdate(StockPriceUpdate update)
    {
        RunOnUI(() =>
        {
            var stock = Stocks.FirstOrDefault(s => s.Symbol == update.Symbol);
            if (stock != null)
            {
                stock.UpdatePrice(update.Price, update.ChangePercent);
            }
        });
    }
    
    private void HandleOrderUpdate(Order order)
    {
        RunOnUI(() =>
        {
            var existing = RecentOrders.FirstOrDefault(o => o.Id == order.Id);
            if (existing != null)
            {
                existing.Update(order);
            }
            else
            {
                RecentOrders.Insert(0, new OrderViewModel(order));
                
                // Keep only last 20 orders
                while (RecentOrders.Count > 20)
                {
                    RecentOrders.RemoveAt(RecentOrders.Count - 1);
                }
            }
        });
    }
    
    private void HandleAlert(Alert alert)
    {
        RunOnUI(() =>
        {
            Alerts.Insert(0, new AlertViewModel(alert));
            
            // Keep only last 50 alerts
            while (Alerts.Count > 50)
            {
                Alerts.RemoveAt(Alerts.Count - 1);
            }
        });
    }
    
    private void RunOnUI(Action action)
    {
        if (_uiContext != null)
        {
            _uiContext.Post(_ => action(), null);
        }
        else
        {
            action();
        }
    }
    
    public event PropertyChangedEventHandler PropertyChanged;
    protected void OnPropertyChanged([CallerMemberName] string name = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    
    public void Dispose()
    {
        _client?.Dispose();
    }
}

Blazor Real-Time Component

csharp
@page "/dashboard"
@implements IDisposable
@inject DashboardClient Client

<div class="dashboard">
    <div class="connection-status @GetStatusClass()">
        @connectionStatus
    </div>
    
    <div class="stocks-grid">
        @foreach (var stock in stocks.Values)
        {
            <StockTile Stock="@stock" />
        }
    </div>
    
    <div class="alerts-feed">
        <h3>Alerts</h3>
        @foreach (var alert in alerts.Take(10))
        {
            <AlertItem Alert="@alert" />
        }
    </div>
    
    <div class="orders-panel">
        <h3>Recent Orders</h3>
        @foreach (var order in orders.Take(5))
        {
            <OrderRow Order="@order" />
        }
    </div>
</div>

@code {
    private Dictionary<string, StockPriceUpdate> stocks = new();
    private List<Alert> alerts = new();
    private List<Order> orders = new();
    private string connectionStatus = "Connecting...";
    
    protected override async Task OnInitializedAsync()
    {
        Client.OnPriceUpdate += HandlePriceUpdate;
        Client.OnOrderUpdate += HandleOrderUpdate;
        Client.OnAlert += HandleAlert;
        Client.OnConnectionStatusChanged += HandleConnectionStatus;
        
        await Client.ConnectAsync();
        
        // Load initial data
        var summary = await Client.GetMarketSummaryAsync();
        foreach (var stock in summary.TopStocks)
        {
            stocks[stock.Symbol] = new StockPriceUpdate
            {
                Symbol = stock.Symbol,
                Price = stock.Price,
                PreviousPrice = stock.PreviousClose
            };
        }
    }
    
    private void HandlePriceUpdate(StockPriceUpdate update)
    {
        InvokeAsync(() =>
        {
            stocks[update.Symbol] = update;
            StateHasChanged();
        });
    }
    
    private void HandleOrderUpdate(Order order)
    {
        InvokeAsync(() =>
        {
            var existing = orders.FindIndex(o => o.Id == order.Id);
            if (existing >= 0)
                orders[existing] = order;
            else
                orders.Insert(0, order);
            
            StateHasChanged();
        });
    }
    
    private void HandleAlert(Alert alert)
    {
        InvokeAsync(() =>
        {
            alerts.Insert(0, alert);
            if (alerts.Count > 50) alerts.RemoveAt(alerts.Count - 1);
            StateHasChanged();
        });
    }
    
    private void HandleConnectionStatus(string status)
    {
        InvokeAsync(() =>
        {
            connectionStatus = status;
            StateHasChanged();
        });
    }
    
    private string GetStatusClass() => connectionStatus switch
    {
        "Connected" => "status-connected",
        _ when connectionStatus.Contains("Reconnecting") => "status-reconnecting",
        _ => "status-disconnected"
    };
    
    public void Dispose()
    {
        Client.OnPriceUpdate -= HandlePriceUpdate;
        Client.OnOrderUpdate -= HandleOrderUpdate;
        Client.OnAlert -= HandleAlert;
        Client.OnConnectionStatusChanged -= HandleConnectionStatus;
    }
}

Event Design Best Practices

1. Keep Event Payloads Small

Events fire frequently. Large payloads create network and serialization overhead:

csharp
// Bad: large payload
public event Action<FullOrderBook> OrderBookUpdated;  // Could be megabytes!

// Good: delta updates
public event Action<OrderBookDelta> OrderBookChanged;

public class OrderBookDelta
{
    public string Symbol { get; set; }
    public List<PriceLevel> BidChanges { get; set; }  // Only what changed
    public List<PriceLevel> AskChanges { get; set; }
    public long SequenceNumber { get; set; }
}

2. Use Specific Event Types

Don't overload a single event with different data:

csharp
// Bad: generic event with type discrimination
public event Action<object, string> DataChanged;  // What data? What type?

// Good: specific events
public event Action<StockPriceUpdate> PriceUpdated;
public event Action<VolumeSpike> VolumeAlert;
public event Action<MarketStatus> MarketStatusChanged;

3. Include Timestamps

Always include when the event occurred:

csharp
public class StockPriceUpdate
{
    public string Symbol { get; set; }
    public decimal Price { get; set; }
    public DateTime Timestamp { get; set; }  // When did this happen?
}

4. Handle High-Frequency Updates

For very frequent events, consider batching or throttling:

csharp
public class ThrottledMarketService : IMarketService
{
    private readonly IMarketService _inner;
    private readonly Dictionary<string, StockPriceUpdate> _pendingUpdates = new();
    private readonly Timer _flushTimer;
    
    public event Action<StockPriceUpdate> PriceUpdated = delegate { };
    public event Action<List<StockPriceUpdate>> BatchPriceUpdated = delegate { };
    
    public ThrottledMarketService(IMarketService inner)
    {
        _inner = inner;
        _inner.PriceUpdated += HandleInnerPriceUpdate;
        
        // Flush batched updates every 100ms
        _flushTimer = new Timer(FlushUpdates, null, 100, 100);
    }
    
    private void HandleInnerPriceUpdate(StockPriceUpdate update)
    {
        lock (_pendingUpdates)
        {
            _pendingUpdates[update.Symbol] = update;  // Keep latest only
        }
    }
    
    private void FlushUpdates(object state)
    {
        List<StockPriceUpdate> batch;
        lock (_pendingUpdates)
        {
            if (_pendingUpdates.Count == 0) return;
            batch = _pendingUpdates.Values.ToList();
            _pendingUpdates.Clear();
        }
        
        // Send as batch
        BatchPriceUpdated(batch);
    }
}

5. Handle UI Thread Marshaling

Events arrive on background threads. Marshal to UI thread:

csharp
// WPF
private void HandlePriceUpdate(StockPriceUpdate update)
{
    Application.Current.Dispatcher.Invoke(() =>
    {
        // Update UI here
    });
}

// Blazor
private void HandlePriceUpdate(StockPriceUpdate update)
{
    InvokeAsync(() =>
    {
        // Update state here
        StateHasChanged();
    });
}

// WinForms
private void HandlePriceUpdate(StockPriceUpdate update)
{
    if (InvokeRequired)
    {
        Invoke(new Action(() => HandlePriceUpdate(update)));
        return;
    }
    // Update UI here
}

6. Resubscribe After Reconnection

Event subscriptions don't survive reconnection:

csharp
options.WithAutoReconnect(reconnect =>
{
    reconnect.OnReconnected = () =>
    {
        // Get fresh proxy
        _service = _client.GetService<IMarketService>();
        
        // Resubscribe to all events
        _service.PriceUpdated += HandlePriceUpdate;
        _service.AlertTriggered += HandleAlert;
        
        // Refresh data that might have changed while disconnected
        Task.Run(async () => await RefreshDataAsync());
    };
});

Performance Considerations

Event Throughput

WitRPC can handle thousands of events per second, but consider:

Factor Impact Mitigation
Payload size Larger = slower Keep payloads minimal
Event frequency Higher = more CPU Batch or throttle
Number of clients More = more server work Scale horizontally
Serialization Complex types slower Use simple types

Monitoring Event Metrics

csharp
public class InstrumentedMarketService : IMarketService
{
    private readonly IMarketService _inner;
    private readonly Counter _eventsRaised;
    private readonly Histogram _eventLatency;
    
    public event Action<StockPriceUpdate> PriceUpdated = delegate { };
    
    public InstrumentedMarketService(IMarketService inner)
    {
        _inner = inner;
        _inner.PriceUpdated += update =>
        {
            var sw = Stopwatch.StartNew();
            PriceUpdated(update);
            sw.Stop();
            
            _eventsRaised.Inc();
            _eventLatency.Observe(sw.Elapsed.TotalMilliseconds);
        };
    }
}

Conclusion

WitRPC events transform how you build real-time applications:

  • Natural event model: Use standard .NET events, no special protocols
  • Automatic delivery: the server raises, clients receive, WitRPC handles the rest
  • Full-duplex: Combine request/response with push notifications
  • Type-safe: Events are part of the interface contract

For dashboards, monitoring tools, trading systems, or any application that needs live updates, WitRPC events provide a clean, efficient solution.

Key takeaways:

  • Define events in your service interface
  • Keep event payloads small and specific
  • Handle UI thread marshaling on the client
  • Resubscribe after reconnection
  • Consider batching for high-frequency updates

Real-time doesn't have to be hard. With WitRPC, it's just events.


This post concludes the series. The docs cover every topic here in reference form, and the benchmark article grounds the performance claims in reproducible data.

This is part 13 of a series on WitRPC. See previous posts: Introducing WitRPC, Getting Started, Real-World Benefits, WitRPC vs. gRPC, Migrating to WitRPC, Under the Hood, Composite Services, ASP.NET Core Integration, Performance Tuning, Resilience, Security, and Production Deployment.