SYSTEM_BLOG
TIME: 00:00:00
STATUS: ONLINE
~/blog/headless-drupal-react-pwa
$ cat headless-drupal-react-pwa.md _
| 2025-11-15 | 18 min

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

TEXT
┌────────────────────────────────────────────────────────────┐
│                     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:

JAVASCRIPT
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:

JAVASCRIPT
// 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(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:

JAVASCRIPT
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:

JAVASCRIPT
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 '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:

JAVASCRIPT
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:

StrategyUse Case
Last Write WinsSimple fields, non-critical data
Server WinsAuthoritative data, prices
Client WinsUser preferences
Manual MergeComplex 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.