The Multi-Tenant Offline Problem

Building offline-first mobile apps is hard. Building them for multiple tenants—where a single user might belong to several organizations, each with isolated datasets—is exponentially harder. The naive approach of syncing everything and filtering client-side fails catastrophically at scale: a healthcare worker managing data for three clinics shouldn't download patient records from all three when opening one clinic's dashboard. Yet most offline sync architectures treat the entire user account as a monolithic sync boundary.

The core challenge isn't technical primitives—CRDTs, operational transforms, and vector clocks are well-understood. The problem is scope explosion: without deliberate boundaries, every sync operation must consider the entire dataset graph, leading to O(n²) conflict detection, bloated local storage, and user-visible lag as the app churns through irrelevant updates.

Domain-Driven Design's concept of bounded contexts offers a principled solution. By treating each tenant's dataset as a separate bounded context with explicit sync boundaries, we can build systems that scale linearly with active context count rather than total data volume.

Bounded Context as Sync Unit

In a multi-tenant mobile app, each bounded context maps to a logical workspace: a clinic in a healthcare app, a project in a construction tool, a store in a retail platform. The key insight: contexts are the unit of sync activation. When a user opens Clinic A, the app activates that context's sync pipeline. Clinic B's data remains dormant, untouched by conflict resolution or merge logic.

Implementation requires three architectural components:

  • Context registry: A lightweight index mapping context IDs to sync endpoints, schema versions, and local storage partitions. Stored in a separate SQLite database or key-value store.
  • Isolated storage: Each context gets its own SQLite database file or IndexedDB namespace. No shared tables except the registry. This prevents accidental cross-context queries and enables atomic context deletion.
  • Lazy activation: Contexts transition between dormant, activating, and active states. Only active contexts participate in sync. Activation loads metadata, establishes WebSocket connections, and hydrates in-memory caches.

A Flutter implementation might use separate Database instances per context:

class ContextStore {
  final Map<String, Database> _databases = {};
  
  Future<Database> activate(String contextId) async {
    if (_databases.containsKey(contextId)) {
      return _databases[contextId]!;
    }
    final path = await _getContextPath(contextId);
    final db = await openDatabase(path);
    _databases[contextId] = db;
    return db;
  }
  
  Future<void> deactivate(String contextId) async {
    final db = _databases.remove(contextId);
    await db?.close();
  }
}

Cross-Context References Without Coupling

Real applications have legitimate cross-context relationships: a user entity spans all contexts, shared lookup tables (ICD codes in healthcare, product catalogs in retail), and reference data. Naive modeling creates tight coupling—updating a shared entity triggers sync in all contexts.

The solution is reference by identity with late binding. Instead of embedding shared data, store stable identifiers and resolve them lazily. A patient record in Clinic A stores the user ID, not a denormalized user object. When rendering, the app fetches the user from a separate, globally-synced shared context.

This introduces a second sync tier: ambient sync for shared, low-volume data that syncs continuously across all contexts, and contextual sync for high-volume, tenant-specific data. Ambient sync uses a separate database and runs in the background. Its conflict resolution is simpler—shared data changes infrequently and is typically system-managed.

In a medical app built for multi-clinic workflows, ambient sync handled ~200 user records and 15,000 ICD-10 codes (12 MB), while contextual sync per clinic ranged from 50 MB to 2 GB. Separating these tiers reduced sync time for context switches from 8 seconds to under 400 ms, because the app no longer re-processed shared data on every activation.

Conflict Resolution Scoped to Context

Bounded contexts dramatically simplify conflict resolution. When two devices edit the same patient record in Clinic A, the conflict is scoped to that context. The resolution logic—last-write-wins, operational transform, or custom merge—operates on a small, domain-specific dataset.

Traditional CRDT implementations struggle with multi-tenancy because they maintain a global version vector or causal graph. In a 50-tenant deployment, the version vector has 50 entries per entity, even though most conflicts occur within a single tenant. By scoping version vectors to contexts, we reduce metadata overhead by ~40× in typical workloads.

A context-scoped LWW register looks like:

{
  "contextId": "clinic-a",
  "entityId": "patient-123",
  "value": { "name": "...", "dob": "..." },
  "timestamp": 1704067200000,
  "deviceId": "device-x",
  "contextVersion": 42
}

The contextVersion is a monotonic counter per context, not global. When syncing, the server only compares versions within the same context. Cross-context writes are impossible by construction—the API enforces context isolation at the authorization layer.

Sync Protocol Design

The sync protocol must support partial activation. A naive sync-everything-then-filter approach defeats the purpose. Instead, use a capability-based protocol where the client declares active contexts:

// WebSocket message on context activation
{
  "type": "ACTIVATE_CONTEXT",
  "contextId": "clinic-a",
  "sinceVersion": 38
}

The server responds with a delta stream: only changes in that context since version 38. This requires the server to maintain per-context change logs, typically using a context_id column in the events table. PostgreSQL's row-level security can enforce isolation:

CREATE POLICY context_isolation ON events
  USING (context_id = current_setting('app.active_context'));

When the user switches contexts, the client sends DEACTIVATE_CONTEXT and ACTIVATE_CONTEXT messages. The server unsubscribes from the old context's change feed and subscribes to the new one. This keeps WebSocket bandwidth proportional to active context count, not total tenant count.

Storage and Pruning

Bounded contexts enable aggressive pruning. When a user leaves an organization, the app deletes that context's database file—no risk of accidentally purging shared data. When storage is tight, the app can prune dormant contexts based on last-access time.

A production healthcare app implemented LRU eviction: contexts inactive for >30 days were candidates for pruning. The app kept the context registry entry but deleted the SQLite file, reducing storage from 4.2 GB to 800 MB for a user with 12 historical clinic affiliations. On next access, the app re-synced from the server—a 2-second operation vs. the multi-minute full sync required by monolithic designs.

Migration and Schema Evolution

Bounded contexts simplify schema migrations. Each context database has its own schema version. When the app updates, it migrates contexts lazily on activation. A clinic the user hasn't visited in months stays at the old schema until accessed.

This avoids the cold-start migration penalty where the app blocks for minutes migrating dozens of dormant datasets. In a fleet of 2,000 devices, lazy migration reduced p95 app launch time from 18 seconds to 1.2 seconds after a major schema update.

When Not to Use This Pattern

Bounded context sync adds complexity. It's overkill for single-tenant apps or apps where users rarely switch contexts. The overhead of managing multiple databases and sync pipelines is only justified when:

  • Users actively work in 2+ tenants per session
  • Per-tenant datasets exceed 50 MB
  • Sync latency or storage is a measurable problem
  • Tenant isolation is a compliance requirement (HIPAA, GDPR)

For simpler cases, a single database with tenant_id columns and client-side filtering suffices. The pattern's value emerges at scale, where the O(n) cost of filtering becomes prohibitive.

Operational Considerations

Debugging multi-context sync requires enhanced telemetry. Log context activations, deactivations, and sync errors per context. A user reporting "data not syncing" might have an issue in one context while others work fine—global logs obscure this.

Server-side, partition metrics by context: sync latency, conflict rate, bandwidth. This reveals per-tenant hotspots. In one deployment, a single clinic generated 60% of conflicts due to a misconfigured integration—something invisible in aggregate metrics.

Testing must cover context switches under poor network conditions. A common bug: the app activates Context B before fully deactivating Context A, leading to cross-context writes or database lock contention. Integration tests should simulate rapid context switching with injected latency.

Conclusion

Bounded contexts transform multi-tenant offline sync from a data management nightmare into a tractable engineering problem. By aligning sync boundaries with domain boundaries, we achieve linear scaling, simpler conflict resolution, and better user experience. The pattern requires upfront architectural investment—separate storage, lazy activation, capability-based protocols—but pays dividends in apps serving diverse, data-intensive workloads. For teams building offline-first platforms where users traverse organizational boundaries, treating contexts as first-class sync primitives is no longer optional—it's the foundation of a scalable architecture.