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:
class 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:
// sw.js
const CACHENAME = 'pwa-cache-v1';
const OFFLINEURL = '/offline.html';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHENAME).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(CACHENAME);
cache.put(request, response.clone());
return response;
} catch (error) {
return caches.match(request);
}
}
## Timestamp-Based Differential Sync
Only sync what changed since last update:
class 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:
class 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 'ENTITYUPDATE':
this.onUpdate(data.entity);
break;
case 'ENTITYDELETE':
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:
function 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.