In today's web development landscape, offline capability isn't just a nice-to-have feature—it's becoming essential. Users expect applications to work seamlessly regardless of network conditions.
However, implementing robust offline functionality typically requires complex libraries and frameworks with numerous dependencies, complicating your build process and increasing your bundle size.
Enter SRVRA-SYNC: a pure JavaScript state management and synchronization library that requires zero dependencies and works without Node.js. This tutorial will guide you through building an offline-first web application that synchronizes data intelligently when connectivity returns.
Why Offline-First Matters
Before diving in, let's clarify why offline-first architecture is crucial:
Improved user experience in unreliable network conditions
Faster perceived performance with instant local data access
Resilience against network interruptions
Better battery life by reducing constant connection attempts
Getting Started with SRVRA-SYNC
Let's start by adding SRVRA-SYNC to our project. Since it has zero dependencies, you can include it directly via a script tag:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline-First Todo App</title>
<!-- No need for npm install or complex build processes -->
<script src="https://cdn.example.com/srvra-sync.min.js"></script>
</head>
<body>
<div id="app">
<h1>My Todos</h1>
<div id="connection-status"></div>
<form id="todo-form">
<input type="text" id="todo-input" placeholder="Add a new todo">
<button type="submit">Add</button>
</form>
<ul id="todo-list"></ul>
</div>
<script src="app.js"></script>
</body>
</html>
Now, let's create our application logic in app.js:
// Initialize the core components
const eventBus = new SrvraEventBus();
const stateManager = new SrvraStateManager();
const dataSync = new SrvraDataSync({
// Adjust sync interval based on your app's needs
syncInterval: 5000,
// Optimize for offline usage
retryAttempts: 5,
enableDeltaUpdates: true
});
// Set up our initial state
stateManager.setState('todos', []);
stateManager.setState('connectionStatus', 'online');
// UI Elements
const todoForm = document.getElementById('todo-form');
const todoInput = document.getElementById('todo-input');
const todoList = document.getElementById('todo-list');
const connectionStatus = document.getElementById('connection-status');
// Track network status
window.addEventListener('online', () => {
stateManager.setState('connectionStatus', 'online');
connectionStatus.textContent = 'Online - Syncing...';
// Trigger sync when connection returns
dataSync.sync();
});
window.addEventListener('offline', () => {
stateManager.setState('connectionStatus', 'offline');
connectionStatus.textContent = 'Offline - Changes saved locally';
});
// Initialize the connection display
connectionStatus.textContent = navigator.onLine ? 'Online' : 'Offline - Changes saved locally';
// Handle adding new todos
todoForm.addEventListener('submit', (e) => {
e.preventDefault();
const todoText = todoInput.value.trim();
if (!todoText) return;
const todos = stateManager.getState('todos') || [];
const newTodo = {
id: Date.now().toString(),
text: todoText,
completed: false,
createdAt: Date.now(),
synced: false
};
// Update local state immediately
stateManager.setState('todos', [...todos, newTodo]);
// Publish event for sync handling
eventBus.publish('data-change', {
key: 'todos',
value: [...todos, newTodo],
timestamp: Date.now()
});
todoInput.value = '';
});
// Subscribe to state changes to update the UI
stateManager.subscribe('todos', renderTodos);
// Render the todo list
function renderTodos(todos) {
todoList.innerHTML = '';
if (!todos || !todos.length) {
todoList.innerHTML = '<li class="empty">No todos yet. Add one above!</li>';
return;
}
todos.forEach(todo => {
const li = document.createElement('li');
li.dataset.id = todo.id;
li.className = todo.completed ? 'completed' : '';
// Add sync status indicator
const syncStatus = todo.synced ? '?' : '?';
li.innerHTML = `
<span class="todo-text">${todo.text}</span>
<span class="sync-status" title="${todo.synced ? 'Synced' : 'Pending sync'}">${syncStatus}</span>
<button class="toggle-btn">${todo.completed ? 'Undo' : 'Complete'}</button>
<button class="delete-btn">Delete</button>
`;
// Add event listeners for toggle and delete
li.querySelector('.toggle-btn').addEventListener('click', () => toggleTodo(todo.id));
li.querySelector('.delete-btn').addEventListener('click', () => deleteTodo(todo.id));
todoList.appendChild(li);
});
}
// Toggle todo completion status
function toggleTodo(id) {
const todos = stateManager.getState('todos');
const updatedTodos = todos.map(todo => {
if (todo.id === id) {
return { ...todo, completed: !todo.completed, synced: false };
}
return todo;
});
stateManager.setState('todos', updatedTodos);
// Publish event for sync
eventBus.publish('data-change', {
key: 'todos',
value: updatedTodos,
timestamp: Date.now()
});
}
// Delete a todo
function deleteTodo(id) {
const todos = stateManager.getState('todos');
const updatedTodos = todos.filter(todo => todo.id !== id);
stateManager.setState('todos', updatedTodos);
// Publish event for sync
eventBus.publish('data-change', {
key: 'todos',
value: updatedTodos,
timestamp: Date.now()
});
}
// Handle synchronization
eventBus.subscribe('sync-complete', (data) => {
const todos = stateManager.getState('todos');
// Mark successfully synced todos
const updatedTodos = todos.map(todo => {
if (!todo.synced) {
return { ...todo, synced: true };
}
return todo;
});
stateManager.setState('todos', updatedTodos);
connectionStatus.textContent = 'Online - Synced';
// After a moment, simplify the display
setTimeout(() => {
if (stateManager.getState('connectionStatus') === 'online') {
connectionStatus.textContent = 'Online';
}
}, 2000);
});
// Listen for sync errors
eventBus.subscribe('sync-error', (data) => {
console.error('Sync error:', data.error);
connectionStatus.textContent = 'Sync failed - Will retry';
});
// Initialize with any stored data
function initializeFromStorage() {
const storedTodos = localStorage.getItem('todos');
if (storedTodos) {
try {
const todos = JSON.parse(storedTodos);
stateManager.setState('todos', todos);
} catch (e) {
console.error('Error loading stored todos:', e);
}
}
}
// Persist state changes to localStorage
stateManager.subscribe('todos', (todos) => {
localStorage.setItem('todos', JSON.stringify(todos));
});
// Initialize
initializeFromStorage();
Setting Up the Server Sync
For a complete offline-first application, we need to set up server synchronization. Here's how to handle the server-side integration:
// This would be added to your app.js
function setupServerSync() {
// Configure sync endpoint
const syncEndpoint = 'https://api.example.com/sync';
// Override the default sendToServer method in SrvraDataSync
dataSync.sendToServer = async function(batch) {
try {
// Only attempt to send if online
if (!navigator.onLine) {
return { success: [], conflicts: [], errors: [] };
}
const response = await fetch(syncEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(batch)
});
if (!response.ok) {
throw new Error(`Server responded with ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Sync error:', error);
// Return empty results but don't fail - we'll retry later
return { success: [], conflicts: [], errors: [error] };
}
};
// Handle conflict resolution
eventBus.subscribe('conflict', (conflict) => {
// For this example, we'll use a "client wins" strategy for conflicts
return dataSync.handleConflict({
...conflict,
forcedStrategy: 'client-wins'
});
});
}
setupServerSync();
Advanced Offline-First Patterns with SRVRA-SYNC
Now let's implement some advanced offline-first patterns:
1. Optimistic UI Updates
// Add this to app.js to enable optimistic UI updates
function optimisticallyUpdateTodo(todoId, changes) {
const todos = stateManager.getState('todos');
// Immediately update the UI
const optimisticTodos = todos.map(todo => {
if (todo.id === todoId) {
return { ...todo, ...changes, synced: false };
}
return todo;
});
// Update state immediately
stateManager.setState('todos', optimisticTodos);
// Then attempt to sync
eventBus.publish('data-change', {
key: 'todos',
value: optimisticTodos,
timestamp: Date.now()
});
}
// Example usage for a priority change
function updateTodoPriority(todoId, priority) {
optimisticallyUpdateTodo(todoId, { priority });
}
2. Conflict Resolution Strategies
// Advanced conflict resolution based on data types
dataSync.conflictResolver.registerCustomStrategy('smart-todo-merge', (conflict) => {
const serverTodo = conflict.serverValue;
const clientTodo = conflict.clientValue;
// If completed status differs, prefer the completed version
const completed = serverTodo.completed || clientTodo.completed;
// For text content, use the most recently edited version
const text = serverTodo.updatedAt > clientTodo.updatedAt
? serverTodo.text
: clientTodo.text;
return {
value: {
...clientTodo,
text,
completed,
synced: true
},
source: 'smart-merge',
metadata: {
mergedAt: Date.now(),
conflictResolution: 'smart-todo-merge'
}
};
});
// Configure todos to use our custom strategy
dataSync.conflictResolver.registerMergeRule('todos',
dataSync.conflictResolver.customStrategies.get('smart-todo-merge'));
3. Storage Quota Management
// Add this to manage localStorage quotas
async function checkStorageQuota() {
if ('storage' in navigator && 'estimate' in navigator.storage) {
const estimate = await navigator.storage.estimate();
const percentUsed = (estimate.usage / estimate.quota) * 100;
if (percentUsed > 80) {
// Alert user or clean up old data
const todos = stateManager.getState('todos');
// Clean up completed and synced todos older than 30 days
const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000);
const cleanedTodos = todos.filter(todo => {
return !(todo.completed && todo.synced && todo.createdAt < thirtyDaysAgo);
});
if (cleanedTodos.length < todos.length) {
stateManager.setState('todos', cleanedTodos);
console.log(`Cleaned up ${todos.length - cleanedTodos.length} old todos to save space.`);
}
}
}
}
// Check quota weekly
setInterval(checkStorageQuota, 7 * 24 * 60 * 60 * 1000);
// Also check on startup
checkStorageQuota();
Handling Network Reconnection Intelligently
An important aspect of offline-first applications is managing reconnection smoothly:
// Add sophisticated reconnection handling
let reconnectionAttempts = 0;
const MAX_BACKOFF = 60000; // Maximum 1 minute between retries
function handleReconnection() {
// Reset if we're online
if (navigator.onLine) {
reconnectionAttempts = 0;
dataSync.sync(); // Sync immediately when we reconnect
return;
}
// Exponential backoff for reconnection attempts
reconnectionAttempts++;
const backoff = Math.min(
1000 * Math.pow(2, reconnectionAttempts),
MAX_BACKOFF
);
connectionStatus.textContent = `Offline - Retrying in ${backoff/1000}s`;
setTimeout(() => {
// Check if we're back online
if (navigator.onLine) {
stateManager.setState('connectionStatus', 'online');
connectionStatus.textContent = 'Online - Syncing...';
dataSync.sync();
} else {
handleReconnection(); // Try again with increased backoff
}
}, backoff);
}
// Initialize network handling
if (!navigator.onLine) {
handleReconnection();
}
// Listen for network changes
window.addEventListener('online', () => {
stateManager.setState('connectionStatus', 'online');
connectionStatus.textContent = 'Online - Syncing...';
dataSync.sync();
});
GitHub Repository:
Srvra-Sync https://github.com/SINHASantos/srvra-sync
Top comments (0)