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:
// 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:
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
// 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
// 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
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
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
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
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
@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:
// 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:
// 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:
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:
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:
// 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:
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
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.