Traditional web applications fail catastrophically without connectivity. Click submit without internet, and your work disappears. Leave a page, and unsaved progress vanishes. For letting agents conducting property inspections in buildings with poor mobile signals or no connectivity, this unreliability makes web-based tools impractical—staff revert to paper checklists and manual data entry back at the office.
Offline-first architecture inverts this model: applications work entirely offline by default, storing data locally and syncing with servers opportunistically when connectivity exists. Week 41 implemented comprehensive offline-first capabilities for LetAdmin's inspection system using IndexedDB for client-side storage, service workers for background sync, and reactive UI patterns for seamless data flow. This article explores how we built inspections that work reliably anywhere.
What Your Team Will Notice
Conducting inspections now works identically online and offline—staff see no difference in functionality based on network status. The interface provides subtle connectivity indicators: a small icon shows "Online" when connected, "Offline" when disconnected, but inspection functionality remains unchanged.
When creating or editing inspections offline, a "pending sync" badge appears on the inspection card. This indicates the inspection exists locally but hasn't yet synced to the server. Staff can continue working, adding observations, uploading photos, and collecting signatures entirely offline.
Once connectivity returns, synchronisation happens automatically in the background. A progress indicator shows sync status: "Syncing 1 of 3 items...", "Uploading photos...", "Complete". Staff don't manually trigger sync—the system handles it transparently without interrupting their workflow.
If sync fails (perhaps due to validation errors or server issues), clear error messages appear with guidance for resolution. Failed syncs don't lose data—inspections remain locally stored until successfully synced, allowing staff to correct issues and retry without re-entering information.
Under the Bonnet: IndexedDB Storage Layer
IndexedDB provides client-side database storage with significantly more capacity than cookies or LocalStorage:
// app/javascript/inspection_storage.js
class InspectionStorage {
constructor() {
this.dbName = 'LetAdminInspections';
this.dbVersion = 1;
this.db = null;
}
async initialize() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Inspections object store
if (!db.objectStoreNames.contains('inspections')) {
const inspectionStore = db.createObjectStore('inspections', {
keyPath: 'local_id',
autoIncrement: true
});
inspectionStore.createIndex('property_id', 'property_id', { unique: false });
inspectionStore.createIndex('status', 'status', { unique: false });
inspectionStore.createIndex('sync_status', 'sync_status', { unique: false });
}
// Photos object store
if (!db.objectStoreNames.contains('inspection_photos')) {
const photoStore = db.createObjectStore('inspection_photos', {
keyPath: 'local_id',
autoIncrement: true
});
photoStore.createIndex('inspection_local_id', 'inspection_local_id', { unique: false });
photoStore.createIndex('sync_status', 'sync_status', { unique: false });
}
};
});
}
async saveInspection(inspection) {
const transaction = this.db.transaction(['inspections'], 'readwrite');
const store = transaction.objectStore('inspections');
inspection.sync_status = 'pending';
inspection.updated_at = new Date().toISOString();
return new Promise((resolve, reject) => {
const request = store.put(inspection);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async getInspection(localId) {
const transaction = this.db.transaction(['inspections'], 'readonly');
const store = transaction.objectStore('inspections');
return new Promise((resolve, reject) => {
const request = store.get(localId);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async getPendingInspections() {
const transaction = this.db.transaction(['inspections'], 'readonly');
const store = transaction.objectStore('inspections');
const index = store.index('sync_status');
return new Promise((resolve, reject) => {
const request = index.getAll('pending');
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async markInspectionSynced(localId, serverId) {
const transaction = this.db.transaction(['inspections'], 'readwrite');
const store = transaction.objectStore('inspections');
return new Promise(async (resolve, reject) => {
const inspection = await this.getInspection(localId);
inspection.sync_status = 'synced';
inspection.server_id = serverId;
inspection.synced_at = new Date().toISOString();
const request = store.put(inspection);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Photo storage methods
async savePhoto(photo) {
const transaction = this.db.transaction(['inspection_photos'], 'readwrite');
const store = transaction.objectStore('inspection_photos');
// Store image as base64 to prevent corruption
if (photo.file && !photo.base64_data) {
photo.base64_data = await this.fileToBase64(photo.file);
delete photo.file; // Don't store file object directly
}
photo.sync_status = 'pending';
photo.created_at = new Date().toISOString();
return new Promise((resolve, reject) => {
const request = store.put(photo);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
}
export default new InspectionStorage();
This storage layer provides a clean API abstracting IndexedDB complexity. The sync_status field tracks whether data needs syncing, whilst server_id links local records to server records after successful sync.
Base64 Encoding for Photo Reliability
Initial implementations stored photos as File objects directly, causing corruption on some mobile browsers. Converting to base64 before storage prevents these issues:
// The problem: File objects don't reliably persist in IndexedDB
await store.put({ file: photoFile }); // May become 0-byte or corrupted
// The solution: Convert to base64 before storage
const base64 = await fileToBase64(photoFile);
await store.put({ base64_data: base64 }); // Reliably persists
Base64 encoding increases storage size by approximately 33%, but ensures photo reliability across all browsers—a worthwhile trade-off for offline functionality.
Background Sync Implementation
The sync service coordinates uploading pending data when connectivity exists:
// app/javascript/inspection_sync.js
import storage from './inspection_storage';
class InspectionSync {
constructor() {
this.isSyncing = false;
this.syncProgress = { current: 0, total: 0 };
}
async syncPendingData() {
if (this.isSyncing) {
console.log('Sync already in progress');
return;
}
if (!navigator.onLine) {
console.log('Offline - skipping sync');
return;
}
this.isSyncing = true;
this.updateSyncUI('syncing');
try {
await storage.initialize();
// Sync inspections first
const pendingInspections = await storage.getPendingInspections();
this.syncProgress.total = pendingInspections.length;
for (let i = 0; i < pendingInspections.length; i++) {
this.syncProgress.current = i + 1;
this.updateProgressIndicator();
const inspection = pendingInspections[i];
await this.syncInspection(inspection);
}
// Then sync photos
await this.syncPendingPhotos();
this.updateSyncUI('completed');
} catch (error) {
console.error('Sync failed:', error);
this.updateSyncUI('failed', error.message);
} finally {
this.isSyncing = false;
}
}
async syncInspection(inspection) {
try {
const response = await fetch('/inspections', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
},
body: JSON.stringify({
inspection: {
property_id: inspection.property_id,
inspection_type: inspection.inspection_type,
inspection_date: inspection.inspection_date,
room_observations: inspection.room_observations,
general_observations: inspection.general_observations,
status: inspection.status
}
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Sync failed');
}
const data = await response.json();
await storage.markInspectionSynced(inspection.local_id, data.id);
return data;
} catch (error) {
console.error('Failed to sync inspection:', error);
throw error;
}
}
async syncPendingPhotos() {
const photos = await storage.getPendingPhotos();
for (const photo of photos) {
// Convert base64 back to Blob for upload
const blob = await this.base64ToBlob(photo.base64_data);
const formData = new FormData();
formData.append('photo[image]', blob, photo.filename || 'photo.jpg');
formData.append('photo[inspection_id]', photo.server_inspection_id);
const response = await fetch(`/inspections/${photo.server_inspection_id}/add_photo`, {
method: 'POST',
headers: {
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
},
body: formData
});
if (response.ok) {
await storage.markPhotoSynced(photo.local_id);
}
}
}
async base64ToBlob(base64Data) {
const response = await fetch(base64Data);
return await response.blob();
}
updateProgressIndicator() {
const { current, total } = this.syncProgress;
const progressBar = document.querySelector('[data-sync-progress]');
if (progressBar) {
progressBar.textContent = `Syncing ${current} of ${total} items...`;
progressBar.style.display = 'block';
}
}
updateSyncUI(status, message = '') {
const syncIndicator = document.querySelector('[data-sync-status]');
if (!syncIndicator) return;
switch (status) {
case 'syncing':
syncIndicator.textContent = 'Syncing...';
syncIndicator.className = 'sync-status syncing';
break;
case 'completed':
syncIndicator.textContent = 'All data synced';
syncIndicator.className = 'sync-status completed';
setTimeout(() => {
syncIndicator.style.display = 'none';
}, 3000);
break;
case 'failed':
syncIndicator.textContent = `Sync failed: ${message}`;
syncIndicator.className = 'sync-status failed';
break;
}
}
}
export default new InspectionSync();
This sync service runs automatically when connectivity returns, triggered by online/offline events and periodic checks.
Automatic Sync Triggers
The application listens for connectivity changes and triggers sync appropriately:
// app/javascript/application.js
import inspectionSync from './inspection_sync';
// Sync when coming online
window.addEventListener('online', () => {
console.log('Network connection restored - starting sync');
inspectionSync.syncPendingData();
});
// Check for pending data on page load
document.addEventListener('DOMContentLoaded', () => {
if (navigator.onLine) {
inspectionSync.syncPendingData();
}
});
// Periodic sync check (every 2 minutes when online)
setInterval(() => {
if (navigator.onLine) {
inspectionSync.syncPendingData();
}
}, 120000);
These triggers ensure data syncs promptly without requiring manual user action.
Testing Offline Functionality
Testing offline behaviour requires simulating network conditions:
describe('InspectionStorage', () => {
let storage;
beforeEach(async () => {
storage = new InspectionStorage();
await storage.initialize();
});
it('saves inspections locally', async () => {
const inspection = {
property_id: 1,
inspection_type: 'routine',
inspection_date: '2025-10-10',
room_observations: { 'living_room': 'Good condition' }
};
const localId = await storage.saveInspection(inspection);
const retrieved = await storage.getInspection(localId);
expect(retrieved.property_id).toBe(1);
expect(retrieved.sync_status).toBe('pending');
});
it('converts photos to base64 for storage', async () => {
const file = new File(['photo content'], 'test.jpg', { type: 'image/jpeg' });
const photo = {
inspection_local_id: 1,
file: file
};
const localId = await storage.savePhoto(photo);
const retrieved = await storage.getPhoto(localId);
expect(retrieved.base64_data).toMatch(/^data:image/);
expect(retrieved.file).toBeUndefined();
});
});
These tests verify storage reliability across browsers and network conditions.
What's Next
The offline-first foundation enables sophisticated features: conflict resolution when multiple staff edit the same inspection offline, partial sync supporting incremental uploads for large photo sets, sync queue management allowing prioritisation of critical data, and offline analytics tracking inspection completion rates without network access.
Future enhancements might include differential sync sending only changed fields rather than entire records, peer-to-peer sync allowing data transfer between devices without server access, and predictive sync preloading likely-needed data before staff go offline.
By implementing comprehensive offline-first architecture, LetAdmin ensures property inspections work reliably regardless of connectivity, transforming mobile inspections from frustratingly unreliable web forms to robust native-app-quality experiences.
