Headless Drupal + React PWA: Real-Time Sync Architecture
# Headless Drupal + React PWA: Real-Time Sync Architecture
Building offline-first applications requires a fundamentally different approach. This architecture combines Drupal's content management power with React's UI capabilities and PWA's offline resilience.
## Architecture Overview
┌────────────────────────────────────────────────────────────┐
│ React PWA Client │
├────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ UI Layer │ │ Service │ │ IndexedDB │ │
│ │ (React) │ │ Worker │ │ (Local Store) │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
└─────────────────────────────┬──────────────────────────────┘
│ WebSocket / REST
┌─────────────────────────────▼──────────────────────────────┐
│ Drupal Backend │
├────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ JSON:API │ │ WebSocket │ │ Content │ │
│ │ Endpoints │ │ Server │ │ Management │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
└────────────────────────────────────────────────────────────┘
## IndexedDB Storage Layer
Local storage powers offline functionality:
javascriptclass LocalStore { constructor(dbName, version = 1) { this.dbName = dbName; this.version = version; this.db = null; } async init() { return new Promise((resolve, reject) => { const request = indexedDB.open(this.dbName, this.version); request.onerror = () => reject(request.error); request.onsuccess = () => { this.db = request.result; resolve(this); }; request.onupgradeneeded = (event) => { const db = event.target.result; // Create object stores if (!db.objectStoreNames.contains('entities')) { const store = db.createObjectStore('entities', { keyPath: 'id' }); store.createIndex('type', 'type'); store.createIndex('modified', 'modified'); } if (!db.objectStoreNames.contains('syncQueue')) { db.createObjectStore('syncQueue', { autoIncrement: true }); } }; }); } async put(storeName, data) { const tx = this.db.transaction(storeName, 'readwrite'); const store = tx.objectStore(storeName); return store.put(data); } async getAll(storeName, indexName, query) { const tx = this.db.transaction(storeName, 'readonly'); const store = tx.objectStore(storeName); const index = indexName ? store.index(indexName) : store; return index.getAll(query); } }
## Service Worker Strategy
The service worker handles caching and background sync:
javascript// sw.js const CACHE_NAME = 'pwa-cache-v1'; const OFFLINE_URL = '/offline.html'; self.addEventListener('install', (event) => { event.waitUntil( caches.open(CACHE_NAME).then((cache) => { return cache.addAll([ '/', '/offline.html', '/static/js/main.js', '/static/css/main.css' ]); }) ); }); self.addEventListener('fetch', (event) => { // Network-first for API calls if (event.request.url.includes('/api/')) { event.respondWith(networkFirst(event.request)); return; } // Cache-first for static assets event.respondWith(cacheFirst(event.request)); }); async function networkFirst(request) { try { const response = await fetch(request); const cache = await caches.open(CACHE_NAME); cache.put(request, response.clone()); return response; } catch (error) { return caches.match(request); } }
## Timestamp-Based Differential Sync
Only sync what changed since last update:
javascriptclass SyncManager { constructor(apiUrl, localStore) { this.apiUrl = apiUrl; this.localStore = localStore; } async sync() { const lastSync = await this.getLastSyncTimestamp(); // Fetch changes from server const response = await fetch( `${this.apiUrl}/sync?since=${lastSync}` ); const { entities, deletions, timestamp } = await response.json(); // Apply updates for (const entity of entities) { await this.localStore.put('entities', entity); } // Handle deletions for (const id of deletions) { await this.localStore.delete('entities', id); } // Push local changes await this.pushLocalChanges(); // Update sync timestamp await this.setLastSyncTimestamp(timestamp); } async pushLocalChanges() { const queue = await this.localStore.getAll('syncQueue'); for (const item of queue) { try { await fetch(`${this.apiUrl}/sync`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(item) }); await this.localStore.delete('syncQueue', item.id); } catch (error) { // Will retry on next sync console.error('Sync failed:', error); } } } }
## WebSocket Real-Time Updates
Live updates push immediately to connected clients:
javascriptclass RealtimeConnection { constructor(wsUrl, onUpdate) { this.wsUrl = wsUrl; this.onUpdate = onUpdate; this.ws = null; this.reconnectAttempts = 0; } connect() { this.ws = new WebSocket(this.wsUrl); this.ws.onopen = () => { console.log('WebSocket connected'); this.reconnectAttempts = 0; }; this.ws.onmessage = (event) => { const data = JSON.parse(event.data); switch (data.type) { case 'ENTITY_UPDATE': this.onUpdate(data.entity); break; case 'ENTITY_DELETE': this.onUpdate({ id: data.id, _deleted: true }); break; } }; this.ws.onclose = () => { this.scheduleReconnect(); }; } scheduleReconnect() { const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000); this.reconnectAttempts++; setTimeout(() => this.connect(), delay); } }
## React Integration
Hooks tie everything together:
javascriptfunction useOfflineData(entityType) { const [data, setData] = useState([]); const [isOnline, setIsOnline] = useState(navigator.onLine); const [isSyncing, setIsSyncing] = useState(false); useEffect(() => { const handleOnline = () => setIsOnline(true); const handleOffline = () => setIsOnline(false); window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; }, []); useEffect(() => { // Load from IndexedDB first localStore.getAll('entities', 'type', entityType) .then(setData); // Then sync if online if (isOnline) { setIsSyncing(true); syncManager.sync() .then(() => localStore.getAll('entities', 'type', entityType)) .then(setData) .finally(() => setIsSyncing(false)); } }, [entityType, isOnline]); return { data, isOnline, isSyncing }; }
## Conflict Resolution
When offline edits conflict with server changes:
| Strategy | Use Case |
|---|---|
| Last Write Wins | Simple fields, non-critical data |
| Server Wins | Authoritative data, prices |
| Client Wins | User preferences |
| Manual Merge | Complex documents |
## Conclusion
Offline-first PWAs require careful orchestration of local storage, sync protocols, and real-time updates. This architecture handles the complexity while providing users with seamless experiences regardless of connectivity.
The combination of Drupal's structured content and React's reactive UI creates applications that feel native while leveraging web technologies.