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)
- Client-side (Browser): IndexedDB for offline; SessionStorage for view state; entity cache with 5-minute TTL
- Server-side (Redis): Entity cache (5-min TTL), presence data (15-min auto-refresh), cache invalidation via pub/sub
- 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:
- Detect reconnection — WebSocket re-establishes; HTTP requests succeed
- Display reconnecting banner:
Reconnecting... Syncing your offline changes. - Fetch server state for all entities in pending change queue
- 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
- Apply changes in timestamp order
- 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>
Related Topics
- Multi-User Collaboration Hub — full feature overview
- Architecture and Presence — real-time system design and presence indicators
- Change Tracking and Audit Trail — full change history and audit logs
- Audit Reports and Retention — compliance reports and data retention
- Comments and Annotations — team collaboration on entities
- Comment Status and UI — approval workflows and display options