Two sales reps, one deal. Sara moves "POS rollout" to Proposal. Omar still has yesterday's page open and marks it Lost a minute later. Who wins?
In most boards I've shipped, the answer was "whoever clicked last," and finance found out when the forecast was already wrong.
Pushing live updates is the easy 20%. The other 80% is stale writes, board isolation, and what happens when the browser reconnects. This post is the short "three bugs" cut for Blazor interactive server + SignalR on .NET 10 — skeleton shapes only. The full board (hub, REST, tests with two fake reps) lives in CRM PRO. Daily tested .NET projects are also in Tech Skill Builder — $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.
Bug 1: last-write-wins with no version
If your move endpoint only takes dealId + newStage, Omar's Lost overwrites Sara's Proposal with no warning. Put a version on the deal and reject mismatches:
public sealed record MoveDealRequest(Guid DealId, PipelineStage To, int ExpectedVersion);
public enum MoveStatus { Moved, NotFound, Conflict, InvalidTransition }
public sealed record MoveDealResult(MoveStatus Status, DealDto? Deal);
Inside one lock (or a DB concurrency token), compare versions before applying the rule:
if (deal.Version != request.ExpectedVersion)
return (new(MoveStatus.Conflict, deal.ToDto()), from);
if (!StageRules.CanMove(from, request.To))
return (new(MoveStatus.InvalidTransition, deal.ToDto()), from);
deal.MoveTo(request.To);
// full implementation in the complete project
On conflict, return the current deal. The losing rep sees reality, not a blank 409.
In production that token is SQL Server rowversion or Postgres xmin — same result shape, catch DbUpdateConcurrencyException.
Bug 2: every board hears every move
Magic-string SendAsync("DealMoved", …) to Clients.All is how "acme" leaks into "globex." Use typed hubs and groups:
public interface IPipelineClient
{
Task DealMoved(DealMovedEvent e);
}
public sealed class PipelineHub(PipelineService pipeline) : Hub<IPipelineClient>
{
public static string GroupFor(string boardId) => $"board:{boardId}";
public async Task<IReadOnlyList<DealDto>> JoinBoard(string boardId)
{
await Groups.AddToGroupAsync(Context.ConnectionId, GroupFor(boardId));
return pipeline.GetBoard(boardId); // snapshot on join
}
public Task<MoveDealResult> MoveDeal(MoveDealRequest request) =>
pipeline.MoveAsync(request, Context.UserIdentifier ?? Context.ConnectionId);
}
Broadcast only on success, and only to that board's group — from a shared service so REST webhooks update open boards the same way:
if (result.Status != MoveStatus.Moved) return result; // nothing broadcast on failure
await hub.Clients.Group(PipelineHub.GroupFor(result.Deal!.BoardId))
.DealMoved(new DealMovedEvent(result.Deal, from, movedBy));
Bug 3: reconnect keeps a zombie board
WithAutomaticReconnect() restores the socket. It does not re-join your group or refresh the snapshot unless you hook it.
_hub = new HubConnectionBuilder()
.WithUrl(Nav.ToAbsoluteUri("/hubs/pipeline"))
.WithAutomaticReconnect()
.Build();
_hub.Reconnected += async _ =>
{
_deals = (await _hub.InvokeAsync<IReadOnlyList<DealDto>>("JoinBoard", _board)).ToList();
await InvokeAsync(StateHasChanged);
};
// full implementation in the complete project — also: never open the hub during prerender
Open the connection in OnAfterRenderAsync(firstRender) only. Prerender + hub = double connect and mysterious empty boards.
Stage rules belong in one place the UI, hub, and REST all call:
public static bool CanMove(PipelineStage from, PipelineStage to) => (from, to) switch
{
(PipelineStage.Won or PipelineStage.Lost, _) => false,
(_, PipelineStage.Lost) => true,
_ => to == from + 1
};
Won/Lost stay terminal. Reopening is a separate audited action, not a stray drag.
Prove it with two fake reps
Don't stop at "the hub starts." Spin two SignalR clients in an integration test, join the same board, race a move with a stale version, and assert one Conflict plus one live DealMoved on the other connection. That test is the whole point of a real-time CRM tutorial.
Want the complete CRM?
CRM PRO is a full .NET 10 + Blazor Clean Architecture CRM with real-time pipeline pieces built in — not just the tutorial board.
For a new tested .NET + AI project every day (full source + PDF), Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.
Have you shipped a SignalR board that silently served stale state after reconnect? Tell me how you caught it.
Top comments (0)