DEV Community

Santosh Sinha
Santosh Sinha

Posted on

Building Offline-First Web Apps with Zero Dependencies: A SRVRA-SYNC Tutorial

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>

Enter fullscreen mode Exit fullscreen mode

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
});

Enter fullscreen mode Exit fullscreen mode

// Set up our initial state

stateManager.setState('todos', []);
stateManager.setState('connectionStatus', 'online');

Enter fullscreen mode Exit fullscreen mode

// 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');

Enter fullscreen mode Exit fullscreen mode

// 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';
});
Enter fullscreen mode Exit fullscreen mode

// Initialize the connection display

connectionStatus.textContent = navigator.onLine ? 'Online' : 'Offline - Changes saved locally';

Enter fullscreen mode Exit fullscreen mode

// 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
    };

Enter fullscreen mode Exit fullscreen mode
// Update local state immediately
Enter fullscreen mode Exit fullscreen mode
    stateManager.setState('todos', [...todos, newTodo]);

Enter fullscreen mode Exit fullscreen mode
// Publish event for sync handling
Enter fullscreen mode Exit fullscreen mode
eventBus.publish('data-change', {
        key: 'todos',
        value: [...todos, newTodo],
        timestamp: Date.now()
    });

    todoInput.value = '';
});

Enter fullscreen mode Exit fullscreen mode

// Subscribe to state changes to update the UI

stateManager.subscribe('todos', renderTodos);

Enter fullscreen mode Exit fullscreen mode

// 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);
    });
}

Enter fullscreen mode Exit fullscreen mode

// 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()
    });
}

Enter fullscreen mode Exit fullscreen mode

// 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()
    });
}

Enter fullscreen mode Exit fullscreen mode

// 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);
});

Enter fullscreen mode Exit fullscreen mode

// Listen for sync errors

eventBus.subscribe('sync-error', (data) => {
    console.error('Sync error:', data.error);
    connectionStatus.textContent = 'Sync failed - Will retry';
});

Enter fullscreen mode Exit fullscreen mode

// 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);
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

// Persist state changes to localStorage

stateManager.subscribe('todos', (todos) => {
    localStorage.setItem('todos', JSON.stringify(todos));
});

Enter fullscreen mode Exit fullscreen mode

// Initialize

initializeFromStorage();

Enter fullscreen mode Exit fullscreen mode

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] };
        }
    };

Enter fullscreen mode Exit fullscreen mode
// Handle conflict resolution
Enter fullscreen mode Exit fullscreen mode
eventBus.subscribe('conflict', (conflict) => {
        // For this example, we'll use a "client wins" strategy for conflicts
        return dataSync.handleConflict({
            ...conflict,
            forcedStrategy: 'client-wins'
        });
    });
}

setupServerSync();

Enter fullscreen mode Exit fullscreen mode

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()
    });
}

Enter fullscreen mode Exit fullscreen mode

// Example usage for a priority change

function updateTodoPriority(todoId, priority) {
    optimisticallyUpdateTodo(todoId, { priority });
}

Enter fullscreen mode Exit fullscreen mode

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'
        }
    };
});

Enter fullscreen mode Exit fullscreen mode

// Configure todos to use our custom strategy

dataSync.conflictResolver.registerMergeRule('todos', 
    dataSync.conflictResolver.customStrategies.get('smart-todo-merge'));
Enter fullscreen mode Exit fullscreen mode

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.`);
            }
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

// Check quota weekly

setInterval(checkStorageQuota, 7 * 24 * 60 * 60 * 1000);

Enter fullscreen mode Exit fullscreen mode

// Also check on startup

checkStorageQuota();

Enter fullscreen mode Exit fullscreen mode

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);
}

Enter fullscreen mode Exit fullscreen mode

// Initialize network handling

if (!navigator.onLine) {
    handleReconnection();
}

// Listen for network changes
window.addEventListener('online', () => {
    stateManager.setState('connectionStatus', 'online');
    connectionStatus.textContent = 'Online - Syncing...';
    dataSync.sync();
});

Enter fullscreen mode Exit fullscreen mode

GitHub Repository:
Srvra-Sync https://github.com/SINHASantos/srvra-sync

Top comments (0)