- 7 minutes to read

Scalability and Offline Editing

This spoke covers enterprise scalability strategies and offline editing capabilities for Mapify's multi-user collaboration. For the full overview, see the Multi-User Collaboration hub.


Scalability Considerations

Mapify's collaboration features are designed to handle large integration landscapes with many simultaneous users.

Scalability Strategies

1. Horizontal Scaling

Deploy multiple Web API instances behind a load balancer:

// Startup.cs — Azure SignalR Service for horizontal scaling
public void ConfigureServices(IServiceCollection services)
{
    services.AddSignalR()
        .AddAzureSignalR(options =>
        {
            options.ConnectionString = Configuration["AzureSignalR:ConnectionString"];
            options.ServerStickyMode = ServerStickyMode.Required;
        });

    services.AddStackExchangeRedisCache(options =>
    {
        options.Configuration = Configuration["Redis:ConnectionString"];
        options.InstanceName = "Mapify_";
    });
}

2. Database Optimization

-- Optimistic locking: fast RowVersion lookups
CREATE NONCLUSTERED INDEX IX_Integrations_RowVersion
ON Integrations (IntegrationId, RowVersion);

-- Audit queries: find recent changes
CREATE NONCLUSTERED INDEX IX_Integrations_LastModified
ON Integrations (LastModifiedDate DESC, LastModifiedBy)
INCLUDE (IntegrationId, Name, Status);

-- Partition audit logs by month for archival
CREATE PARTITION FUNCTION PF_AuditByMonth (DATETIME2)
AS RANGE RIGHT FOR VALUES (
    '2026-01-01', '2026-02-01', '2026-03-01', '2026-04-01',
    '2026-05-01', '2026-06-01', '2026-07-01', '2026-08-01',
    '2026-09-01', '2026-10-01', '2026-11-01', '2026-12-01'
);

3. Caching Strategy (Three Tiers)

  1. Client-side (Browser): IndexedDB for offline; SessionStorage for view state; entity cache with 5-minute TTL
  2. Server-side (Redis): Entity cache (5-min TTL), presence data (15-min auto-refresh), cache invalidation via pub/sub
  3. Database (SQL Server): Query result caching, indexed views for complex joins

Redis implementation:

public async Task<Integration> GetIntegrationAsync(Guid integrationId)
{
    string cacheKey = $"integration:{integrationId}";

    var cachedData = await _cache.GetStringAsync(cacheKey);
    if (cachedData != null)
        return JsonSerializer.Deserialize<Integration>(cachedData);

    var integration = await _context.Integrations
        .FirstOrDefaultAsync(i => i.IntegrationId == integrationId);

    if (integration != null)
    {
        var cacheOptions = new DistributedCacheEntryOptions
            { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) };
        await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(integration), cacheOptions);
    }

    return integration;
}

public async Task UpdateIntegrationAsync(Integration integration)
{
    _context.Integrations.Update(integration);
    await _context.SaveChangesAsync();

    // Invalidate cache and broadcast
    await _cache.RemoveAsync($"integration:{integration.IntegrationId}");
    await _signalRHub.Clients.All.SendAsync("IntegrationUpdated", integration.IntegrationId);
}

4. Message Queues for Async Processing

Use cases: audit log writes, email notifications, search index updates, webhook triggers

public async Task UpdateIntegrationAsync(Integration integration)
{
    // Synchronous: user waits
    _context.Integrations.Update(integration);
    await _context.SaveChangesAsync();

    // Asynchronous: fire-and-forget for audit, notifications
    var message = new ServiceBusMessage(JsonSerializer.Serialize(new
    {
        EventType = "IntegrationUpdated",
        IntegrationId = integration.IntegrationId,
        ChangedBy = _currentUser.Username,
        ChangedDate = DateTime.UtcNow
    }));
    await _serviceBusClient.SendMessageAsync(message);
}

public class AuditLogWorker : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var message in _receiver.ReceiveMessagesAsync(stoppingToken))
        {
            var eventData = JsonSerializer.Deserialize<IntegrationUpdatedEvent>(message.Body);
            await _auditRepository.LogChangeAsync(eventData);
            await _receiver.CompleteMessageAsync(message);
        }
    }
}

5. SignalR Scalability Tiers

Tier Max Connections Max Messages/sec Use Case
Free 20 connections 20 msg/sec Development, proof-of-concept
Standard 1,000 per unit 1,000/sec per unit Small-medium deployments (1-10 units)
Premium 1,000 per unit 1,000/sec per unit Enterprise (HA, geo-replication, 100+ units)

Scaling formula for 100 users:

  • 100 WebSocket connections
  • 100 edits/min ≈ 2 messages/sec
  • Broadcast to 100 users = 200 messages/sec total
  • Required: 1 Standard unit

Cost optimization:

  • Disconnect idle users after 15 minutes
  • Batch presence updates every 2 seconds (not real-time per keystroke)
  • Selective broadcasting (only to users viewing the same entity)

Offline Editing & Sync-on-Reconnect

Mapify supports offline editing for remote sites, VPN disconnects, and mobile users.

Offline Capabilities

Capability Available Offline
View cached entities (IndexedDB) ✅ Yes
Edit entities (saved to local queue) ✅ Yes
Create new entities (temp client-side IDs) ✅ Yes
Delete entities (soft delete in local queue) ✅ Yes
Search cached entities ✅ Yes
Export current view to Excel ✅ Yes
Fetch new entities from server ❌ No
Real-time presence indicators ❌ No
Receive updates from other users ❌ No
Upload attachments or images ❌ No

Offline Mode UI

When Mapify detects disconnection (WebSocket closed, HTTP failing), a prominent banner appears:

<div class="offline-banner" role="alert" aria-live="assertive">
    <i class="fas fa-wifi-slash" aria-hidden="true"></i>
    <strong>You are offline.</strong>
    Changes will be saved locally and synced when your connection is restored.
    <button onclick="retryConnection()" aria-label="Retry connection">
        <i class="fas fa-rotate" aria-hidden="true"></i> Retry
    </button>
</div>
.offline-banner {
    position: fixed;
    top: 0; left: 0; right: 0;
    background-color: #ff9800;
    color: #000;
    padding: 12px 16px;
    display: flex;
    align-items: center;
    gap: 12px;
    font-weight: 500;
    z-index: 10000;
    box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}

Local Change Queue (IndexedDB Schema)

const dbSchema = {
    name: 'MapifyOfflineQueue',
    version: 1,
    stores: [
        {
            name: 'pendingChanges',
            keyPath: 'changeId',
            indexes: [
                { name: 'entityId', keyPath: 'entityId' },
                { name: 'timestamp', keyPath: 'timestamp' }
            ]
        },
        {
            name: 'cachedEntities',
            keyPath: 'entityId',
            indexes: [
                { name: 'entityType', keyPath: 'entityType' },
                { name: 'cachedDate', keyPath: 'cachedDate' }
            ]
        }
    ]
};

// Example pending change record
const pendingChange = {
    changeId: 'change_1234567890',
    entityType: 'Integration',
    entityId: '550e8400-e29b-41d4-a716-446655440000',
    action: 'UPDATE',  // 'CREATE', 'UPDATE', 'DELETE'
    changes: {
        Description: 'New description added offline',
        Owner: 'alice@contoso.com'
    },
    originalRowVersion: 'AAAAAAAAB9E=',  // For conflict detection on sync
    timestamp: '2026-01-19T10:30:45Z',
    syncStatus: 'pending',  // 'pending', 'syncing', 'synced', 'conflict'
    userId: 'alice@contoso.com'
};

Sync-on-Reconnect Flow

When connection is restored:

  1. Detect reconnection — WebSocket re-establishes; HTTP requests succeed
  2. Display reconnecting banner: Reconnecting... Syncing your offline changes.
  3. Fetch server state for all entities in pending change queue
  4. Compare local vs. server state:
    • No server changes → apply local changes directly → Success
    • Server changed different fields → merge automatically → Success
    • Server changed same fields → show conflict dialog → manual resolution
  5. Apply changes in timestamp order
  6. Display sync summary: ✅ Reconnected – 3 changes synced, 1 conflict requires review

JavaScript sync logic:

async function syncOfflineChanges() {
    const db = await openIndexedDB('MapifyOfflineQueue');
    const pendingChanges = await db.getAll('pendingChanges');

    if (pendingChanges.length === 0) {
        showToast('No offline changes to sync', 'info');
        return;
    }

    let successCount = 0;
    const conflicts = [];

    for (const change of pendingChanges) {
        const serverState = await fetch(
            `/api/${change.entityType}s/${change.entityId}`
        ).then(r => r.json());

        if (serverState.rowVersion !== change.originalRowVersion) {
            const hasConflict = detectFieldConflicts(change.changes, serverState);
            if (hasConflict) {
                conflicts.push({ change, serverState });
                continue;
            }
        }

        const response = await fetch(
            `/api/${change.entityType}s/${change.entityId}`,
            {
                method: 'PUT',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ ...serverState, ...change.changes })
            }
        );

        if (response.ok) {
            successCount++;
            await db.delete('pendingChanges', change.changeId);
        } else {
            conflicts.push({ change, error: response.statusText });
        }
    }

    if (conflicts.length > 0) showConflictResolutionDialog(conflicts);

    showToast(
        `Synced ${successCount} changes. ${conflicts.length} conflicts require review.`,
        conflicts.length > 0 ? 'warning' : 'success'
    );
}

Offline Conflict Resolution UI

<div class="offline-sync-conflicts" role="dialog" aria-labelledby="sync-conflicts-title">
    <h2 id="sync-conflicts-title">
        <i class="fas fa-triangle-exclamation" aria-hidden="true"></i>
        Offline Sync Conflicts (3)
    </h2>
    <p>These entities were modified by other users while you were offline:</p>

    <ul class="conflict-list">
        <li class="conflict-item">
            <h3>Integration: SAP to Salesforce (#123)</h3>
            <p>Modified by <strong>Bob Taylor</strong> on 2026-01-19 at 10:25 AM</p>

            <table role="table" aria-label="Field conflicts for Integration 123">
                <thead>
                    <tr>
                        <th scope="col">Field</th>
                        <th scope="col">Your Change</th>
                        <th scope="col">Server Value</th>
                        <th scope="col">Keep</th>
                    </tr>
                </thead>
                <tbody>
                    <tr>
                        <td><strong>Description</strong></td>
                        <td>Updated offline description</td>
                        <td>Bob's new description</td>
                        <td>
                            <select aria-label="Choose value for Description field">
                                <option value="local">My change</option>
                                <option value="server" selected>Server change</option>
                                <option value="merge">Merge both</option>
                            </select>
                        </td>
                    </tr>
                </tbody>
            </table>
            <button onclick="resolveConflict(123)" class="btn-primary">Apply Resolution</button>
        </li>
    </ul>

    <div class="dialog-actions">
        <button onclick="acceptAllServerChanges()" class="btn-secondary">Accept All Server Changes</button>
        <button onclick="closeDialog()" class="btn-secondary">Review Later</button>
    </div>
</div>