Architecture and Presence Indicators
This spoke covers the technical architecture behind Mapify's real-time collaboration and user presence indicators. For the full collaboration overview, see the Multi-User Collaboration hub.
System Architecture
Mapify's multi-user collaboration uses a client-server-database architecture with real-time bidirectional communication via SignalR:
Alice] C2[Browser Client 2
Bob] C3[Browser Client 3
Charlie] end
subgraph "Server Layer"
API[Nodinite Web API<br/>REST + SignalR]
SignalR[SignalR Hub<br/>Real-Time Events]
Cache[Redis Cache<br/>Presence & Session Data]
end
subgraph "Data Layer"
DB[(SQL Server<br/>Repository Model)]
AuditDB[(Audit Database<br/>Change History)]
Queue[Message Queue<br/>Change Events]
end
C1 <--> | WebSocket | SignalR
C2 <--> | WebSocket | SignalR
C3 <--> | WebSocket | SignalR
C1 <--> | HTTPS REST | API
C2 <--> | HTTPS REST | API
C3 <--> | HTTPS REST | API
SignalR --> Cache
API --> Cache
API <--> | Read/Write | DB
API --> | Log Changes | AuditDB
API --> | Publish Events | Queue
Queue --> | Subscribe | SignalR
SignalR -.-> | Broadcast | C1
SignalR -.-> | Broadcast | C2
SignalR -.-> | Broadcast | C3
Architecture components:
| Component | Technology | Purpose | Scalability |
|---|---|---|---|
| Browser Clients | JavaScript, WebSocket, IndexedDB | UI, offline editing, local caching | Unlimited clients |
| Web API | ASP.NET Core, REST, SignalR | Business logic, authentication, validation | Horizontal (load balancer + multiple instances) |
| SignalR Hub | ASP.NET Core SignalR (WebSocket/SSE) | Real-time bidirectional communication | Scaled with Redis backplane |
| Redis Cache | Redis 6.x | Presence data, session state, pub/sub | Master-replica for HA |
| SQL Server | SQL Server 2019+ | Repository Model data | AlwaysOn Availability Groups |
| Audit Database | SQL Server 2019+ | Change history, audit logs (partitioned by date) | Read replicas for reporting |
| Message Queue | Azure Service Bus or RabbitMQ | Async event processing, guaranteed delivery | Partitioned queues for scale |
Real-Time Update Mechanism
Mapify uses SignalR over WebSocket for real-time updates with:
- Bidirectional communication – server pushes updates to clients without polling
- Automatic fallback – WebSocket → Server-Sent Events → Long Polling
- Connection resilience – automatic reconnection with exponential backoff
- Redis backplane – enables horizontal scaling across multiple Web API instances
Update Flow (Step-by-Step)
When User A saves a change:
- Client sends HTTPS POST to
/api/integrations/{id}with updated entity data - Web API validates (authentication, authorization, business rules)
- Web API writes to SQL Server via optimistic locking (checks
RowVersion) - If lock succeeds:
- Transaction commits
- Web API logs change to Audit Database
- Web API publishes
IntegrationUpdatedevent to Message Queue
- SignalR Hub subscribes to Message Queue and receives event
- SignalR broadcasts to all connected clients:
"IntegrationUpdated", integrationId, changedFields, userId, timestamp - Connected clients receive WebSocket message:
- Entity currently displayed → auto-refresh
- Entity not displayed → show toast "Integration #123 updated by Alice"
- User editing same entity → show conflict warning before save
Optimistic vs Pessimistic Locking
Mapify uses optimistic locking for most scenarios and pessimistic locking for critical configurations.
| Aspect | Optimistic | Pessimistic |
|---|---|---|
| Philosophy | Assume conflicts rare; detect and resolve when they occur | Prevent conflicts by locking before editing |
| Implementation | RowVersion field; increment on each save; reject if mismatch | Exclusive lock in database or Redis |
| User Experience | Free editing; conflict dialog only if simultaneous edit | "Entity locked by Alice" message; must wait for release |
| Best For | Integrations, Resources, Services, Custom Metadata | Critical configurations (Web API settings, license keys) |
| Conflict Rate | Low — conflicts only occur when two users edit the same field simultaneously | 0% — by design, only one editor at a time |
| Failure Mode | User sees conflict dialog and chooses resolution | User cannot acquire lock; waits or requests admin override |
C# Optimistic Locking Implementation
public async Task<bool> UpdateIntegrationAsync(Integration updatedEntity, byte[] clientRowVersion)
{
var currentEntity = await _context.Integrations
.FirstOrDefaultAsync(i => i.IntegrationId == updatedEntity.IntegrationId);
if (currentEntity == null)
throw new NotFoundException("Integration not found");
// Optimistic lock check
if (!currentEntity.RowVersion.SequenceEqual(clientRowVersion))
{
throw new ConcurrencyException(
"This integration was modified by another user. Please refresh and try again.",
currentEntity, // current server state
updatedEntity // client's attempted changes
);
}
_context.Entry(currentEntity).CurrentValues.SetValues(updatedEntity);
currentEntity.LastModifiedBy = _currentUser.Username;
currentEntity.LastModifiedDate = DateTime.UtcNow;
await _context.SaveChangesAsync(); // RowVersion auto-increments
await _signalRHub.Clients.All.SendAsync("IntegrationUpdated",
currentEntity.IntegrationId,
_currentUser.Username,
DateTime.UtcNow);
return true;
}
Client-Side Conflict Handling
async function saveIntegration(integration) {
try {
const response = await fetch(`/api/integrations/${integration.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'If-Match': integration.rowVersion
},
body: JSON.stringify(integration)
});
if (response.status === 409) { // 409 Conflict
const conflict = await response.json();
showConflictDialog(conflict.current, conflict.attempted, integration);
} else {
showToast('Integration saved successfully', 'success');
}
} catch (error) {
showToast('Failed to save: ' + error.message, 'error');
}
}
Conflict dialog:
┌───────────────────────────────────────────────────────────┐
│ Conflict Detected │
├───────────────────────────────────────────────────────────┤
│ Integration #123 was modified by Alice while you │
│ were editing. The Status field has conflicting values: │
│ │
│ Your change: "In Review" │
│ Alice's change: "Deprecated" │
│ │
│ ○ Keep Alice's change ("Deprecated") │
│ ● Overwrite with my change ("In Review") │
│ ○ Cancel and review manually │
│ │
│ [View Full Change History] [Cancel] [Save Choice] │
└───────────────────────────────────────────────────────────┘
User Presence Indicators
Presence indicators show who is currently viewing or editing each entity to prevent conflicts and foster team awareness.
Presence Data Tracked
| Field | Example | Purpose |
|---|---|---|
| User ID | alice@contoso.com |
Identify the user |
| Display Name | Alice Johnson |
Human-readable name |
| Avatar URL | https://cdn.nodinite.com/avatars/alice.jpg |
Profile picture |
| Connection Status | online, idle, offline |
Current availability |
| Current View | Integration #123 |
Which entity they are viewing |
| Current Action | viewing, editing, commenting |
What they are doing |
| Last Activity | 2026-01-19 10:30:45 UTC |
When last active |
| Session Duration | 25 minutes |
How long connected |
On-Node Avatar Badges
Active users appear as avatar badges on the graph node:
<div class="graph-node" data-entity-id="123">
<div class="node-content">
<h3>Integration: SAP to Salesforce</h3>
<p>Status: Active</p>
</div>
<div class="presence-badges" role="list" aria-label="Users viewing this entity">
<!-- Alice is editing -->
<div class="presence-badge presence-editing" role="listitem"
aria-label="Alice Johnson is currently editing this integration">
<img src="https://cdn.nodinite.com/avatars/alice.jpg"
alt="Alice Johnson" class="avatar">
<span class="presence-indicator editing" aria-hidden="true">
<i class="fas fa-pencil"></i>
</span>
<span class="presence-tooltip">Alice Johnson (Editing)</span>
</div>
<!-- Bob is viewing -->
<div class="presence-badge presence-viewing" role="listitem"
aria-label="Bob Taylor is currently viewing this integration">
<img src="https://cdn.nodinite.com/avatars/bob.jpg"
alt="Bob Taylor" class="avatar">
<span class="presence-indicator viewing" aria-hidden="true">
<i class="fas fa-eye"></i>
</span>
<span class="presence-tooltip">Bob Taylor (Viewing)</span>
</div>
</div>
</div>
.presence-badges {
position: absolute;
bottom: 8px;
right: 8px;
display: flex;
gap: 4px;
}
.presence-badge {
position: relative;
width: 32px;
height: 32px;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
cursor: pointer;
}
.presence-badge .avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.presence-indicator {
position: absolute;
bottom: -2px;
right: -2px;
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 8px;
border: 2px solid #fff;
}
.presence-indicator.editing { background-color: #2196F3; color: #fff; }
.presence-indicator.viewing { background-color: #4CAF50; color: #fff; }
.presence-indicator.commenting { background-color: #FF9800; color: #fff; }
.presence-tooltip {
position: absolute;
bottom: 120%;
left: 50%;
transform: translateX(-50%);
background-color: #333;
color: #fff;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
.presence-badge:hover .presence-tooltip { opacity: 1; }
Activity Panel
The Activity Panel (side panel or bottom drawer) shows all active users:
<aside class="activity-panel" aria-label="Active users panel">
<h2>
<i class="fas fa-users" aria-hidden="true"></i>
Active Users (3)
</h2>
<ul class="active-users-list" role="list">
<li class="active-user" role="listitem">
<img src="avatar-alice.jpg" alt="Alice Johnson" class="user-avatar">
<div class="user-info">
<div class="user-name">Alice Johnson</div>
<div class="user-activity">
<i class="fas fa-pencil activity-icon editing" aria-hidden="true"></i>
<span>Editing: <a href="#integration-123">Integration #123</a></span>
</div>
<div class="user-last-activity">
Last activity: <time datetime="2026-01-19T10:30:45Z">Just now</time>
</div>
</div>
</li>
<!-- Additional users follow same pattern -->
</ul>
</aside>
Presence State Management
Connection lifecycle:
- User opens Mapify → WebSocket establishes with SignalR Hub
- SignalR Hub registers user → stores presence data in Redis Cache
- User navigates to entity → client sends
"ViewingEntity", entityId - SignalR broadcasts presence → all clients update avatar badges
- User starts editing → client sends
"EditingEntity", entityId - Presence indicator updates → badge changes from to
- User navigates away → client sends
"LeftEntity", entityId - Badge removed from all clients
- User disconnects → Hub removes presence data from Redis
Idle timeout:
- After 5 minutes idle → user marked as
idle; avatar becomes semi-transparent - After 15 minutes idle → user removed from active users list
Scalability:
- Presence data stored in Redis (not SQL) for fast lookup
- SignalR uses Redis backplane to sync presence across all Web API instances
- Presence updates batched (max 1 update per 2 seconds per user)
Related Topics
- Multi-User Collaboration Hub — full feature overview
- Scalability and Offline Editing — performance targets and offline support
- Change Tracking and Audit Trail — who changed what, when, and why
- Audit Reports and Retention — compliance reports and data retention
- Comments and Annotations — team collaboration directly on entities
- Comment Status and UI — approval workflows and display options