- 8 minutes to read

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:

graph TB subgraph "Client Layer" C1[Browser Client 1
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:

  1. Client sends HTTPS POST to /api/integrations/{id} with updated entity data
  2. Web API validates (authentication, authorization, business rules)
  3. Web API writes to SQL Server via optimistic locking (checks RowVersion)
  4. If lock succeeds:
    • Transaction commits
    • Web API logs change to Audit Database
    • Web API publishes IntegrationUpdated event to Message Queue
  5. SignalR Hub subscribes to Message Queue and receives event
  6. SignalR broadcasts to all connected clients: "IntegrationUpdated", integrationId, changedFields, userId, timestamp
  7. 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:

  1. User opens Mapify → WebSocket establishes with SignalR Hub
  2. SignalR Hub registers user → stores presence data in Redis Cache
  3. User navigates to entity → client sends "ViewingEntity", entityId
  4. SignalR broadcasts presence → all clients update avatar badges
  5. User starts editing → client sends "EditingEntity", entityId
  6. Presence indicator updates → badge changes from to
  7. User navigates away → client sends "LeftEntity", entityId
  8. Badge removed from all clients
  9. 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)