Building an Offline-First Task Manager
In today’s web development landscape, creating applications that work seamlessly regardless of network connectivity has become increasingly important. Users expect their applications to function smoothly whether they’re on a stable WiFi connection, experiencing spotty mobile data, or completely offline. This is where IndexedDB shines as a powerful browser-based storage solution.-
IndexedDB is a low-level API for client-side storage of significant amounts of structured data, including files and blobs. Unlike localStorage, which is limited to storing simple key-value pairs with a 5–10MB cap, IndexedDB can store much larger amounts of data and supports complex querying through indexes. It’s asynchronous, which means it won’t block your main thread and freeze your UI during data operations.
In this comprehensive guide, we’ll build a complete offline-first task manager application from the ground up. You’ll learn how to initialize an IndexedDB database, perform CRUD (Create, Read, Update, Delete) operations, work with indexes for efficient querying, handle versioning, and implement synchronization strategies. By the end of this tutorial, you’ll have a fully functional task manager that works perfectly offline and syncs when online.
Understanding IndexedDB Fundamentals
Before diving into code, let’s understand the core concepts of IndexedDB:
Databases: The top-level container that holds object stores. Your application can have multiple databases, though typically you’ll use just one.
Object Stores: Similar to tables in SQL databases, these store your data records. Each object store contains JavaScript objects.
Indexes: These allow you to query your data by properties other than the primary key, enabling fast lookups.
Transactions: All database operations must happen within transactions, which ensure data integrity. Transactions can be read-only or read-write.
Cursors: These allow you to iterate over multiple records in an object store or index.
Setting Up the Database
Let’s start by creating a robust database initialization function for our task manager:
class TaskDB {
constructor() {
this.db = null;
this.dbName = 'TaskManagerDB';
this.version = 1;
}
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = () => {
reject(new Error('Failed to open database'));
};
request.onsuccess = (event) => {
this.db = event.target.result;
console.log('Database initialized successfully');
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create tasks object store if it doesn't exist
if (!db.objectStoreNames.contains('tasks')) {
const objectStore = db.createObjectStore('tasks', {
keyPath: 'id',
autoIncrement: true
});
// Create indexes for efficient querying
objectStore.createIndex('status', 'status', { unique: false });
objectStore.createIndex('priority', 'priority', { unique: false });
objectStore.createIndex('dueDate', 'dueDate', { unique: false });
objectStore.createIndex('createdAt', 'createdAt', { unique: false });
console.log('Object store and indexes created');
}
};
});
}
}
This code creates a TaskDB class that encapsulates all database operations. The init() method opens a connection to IndexedDB. The onupgradeneeded event fires when the database is created for the first time or when the version number increases. Inside this event, we create a tasks object store with id as the primary key (with auto-increment enabled), and we create several indexes on status, priority, dueDate, and createdAt fields. These indexes will allow us to quickly filter tasks by these properties later.
Creating Tasks (Insert Operations)
Now let’s implement the functionality to add new tasks to our database:
class TaskDB {
// ... previous code ...
async addTask(task) {
return new Promise((resolve, reject) => {
// Add timestamp if not present
const taskData = {
...task,
createdAt: task.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString()
};
const transaction = this.db.transaction(['tasks'], 'readwrite');
const objectStore = transaction.objectStore('tasks');
const request = objectStore.add(taskData);
request.onsuccess = () => {
console.log('Task added with ID:', request.result);
resolve(request.result); // Returns the auto-generated ID
};
request.onerror = () => {
reject(new Error('Failed to add task'));
};
});
}
async addMultipleTasks(tasks) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['tasks'], 'readwrite');
const objectStore = transaction.objectStore('tasks');
const addedIds = [];
tasks.forEach((task) => {
const taskData = {
...task,
createdAt: task.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString()
};
const request = objectStore.add(taskData);
request.onsuccess = () => {
addedIds.push(request.result);
};
});
transaction.oncomplete = () => {
console.log('All tasks added successfully');
resolve(addedIds);
};
transaction.onerror = () => {
reject(new Error('Transaction failed'));
};
});
}
}
// Usage example
const taskDB = new TaskDB();
await taskDB.init();
const newTask = {
title: 'Complete IndexedDB tutorial',
description: 'Write comprehensive guide on IndexedDB',
status: 'in-progress',
priority: 'high',
dueDate: '2024-02-01'
};
const taskId = await taskDB.addTask(newTask);
console.log('Created task with ID:', taskId);
The addTask method creates a read-write transaction on the tasks object store, adds timestamp fields automatically, and inserts the task. When successful, it returns the auto-generated ID (for example, 1 for the first task). The addMultipleTasks method demonstrates batch operations, which are more efficient because they use a single transaction for multiple records. This would log something like “Created task with ID: 1” and return the numeric ID that can be used to reference this task later.
Reading Tasks (Retrieve Operations)
Let’s implement various methods to retrieve tasks from the database:
class TaskDB {
// ... previous code ...
async getTask(id) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['tasks'], 'readonly');
const objectStore = transaction.objectStore('tasks');
const request = objectStore.get(id);
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject(new Error('Failed to retrieve task'));
};
});
}
async getAllTasks() {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['tasks'], 'readonly');
const objectStore = transaction.objectStore('tasks');
const request = objectStore.getAll();
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject(new Error('Failed to retrieve tasks'));
};
});
}
async getTasksByStatus(status) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['tasks'], 'readonly');
const objectStore = transaction.objectStore('tasks');
const index = objectStore.index('status');
const request = index.getAll(status);
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject(new Error('Failed to retrieve tasks by status'));
};
});
}
async getTasksByPriority(priority) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['tasks'], 'readonly');
const objectStore = transaction.objectStore('tasks');
const index = objectStore.index('priority');
const request = index.getAll(priority);
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject(new Error('Failed to retrieve tasks by priority'));
};
});
}
}
// Usage examples
const task = await taskDB.getTask(1);
console.log('Retrieved task:', task);
// Output: { id: 1, title: 'Complete IndexedDB tutorial', status: 'in-progress', ... }
const allTasks = await taskDB.getAllTasks();
console.log('Total tasks:', allTasks.length);
// Output: Total tasks: 5
const highPriorityTasks = await taskDB.getTasksByPriority('high');
console.log('High priority tasks:', highPriorityTasks);
// Output: Array of all tasks with priority: 'high'
These methods demonstrate different retrieval strategies. getTask retrieves a single task by its primary key ID, returning the complete task object or undefined if not found. getAllTasks retrieves every task in the database as an array. The getTasksByStatus and getTasksByPriority methods leverage the indexes we created earlier, allowing for efficient filtering without having to scan all records. For instance, if you have 1,000 tasks and want only the high-priority ones, the index makes this operation extremely fast.
Advanced Querying with Cursors
For more complex queries and custom filtering, we’ll use cursors:
class TaskDB {
// ... previous code ...
async getTasksWithCursor(filterFn = null) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['tasks'], 'readonly');
const objectStore = transaction.objectStore('tasks');
const request = objectStore.openCursor();
const results = [];
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const task = cursor.value;
// Apply filter function if provided
if (!filterFn || filterFn(task)) {
results.push(task);
}
cursor.continue(); // Move to next record
} else {
// No more records
resolve(results);
}
};
request.onerror = () => {
reject(new Error('Cursor operation failed'));
};
});
}
async getOverdueTasks() {
const today = new Date().toISOString().split('T')[0];
return this.getTasksWithCursor((task) => {
return task.dueDate < today && task.status !== 'completed';
});
}
async getTasksInDateRange(startDate, endDate) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['tasks'], 'readonly');
const objectStore = transaction.objectStore('tasks');
const index = objectStore.index('dueDate');
// Create a key range
const keyRange = IDBKeyRange.bound(startDate, endDate);
const request = index.openCursor(keyRange);
const results = [];
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
results.push(cursor.value);
cursor.continue();
} else {
resolve(results);
}
};
request.onerror = () => {
reject(new Error('Failed to retrieve tasks in date range'));
};
});
}
}
// Usage examples
const overdueTasks = await taskDB.getOverdueTasks();
console.log('Overdue tasks:', overdueTasks.length);
// Output: Overdue tasks: 3
const januaryTasks = await taskDB.getTasksInDateRange('2024-01-01', '2024-01-31');
console.log('Tasks due in January:', januaryTasks);
// Output: Array of tasks with due dates between Jan 1-31, 2024
Cursors provide fine-grained control over record iteration. The getTasksWithCursor method shows how to iterate through all records and apply custom filtering logic. The getOverdueTasks method uses this to find tasks past their due date that aren’t completed. The getTasksInDateRange demonstrates using IDBKeyRange with an index cursor to efficiently retrieve tasks within a specific date range without having to check every single task. If you have 500 tasks and only 20 are in January, the key range ensures you only process those 20 records.
Updating Tasks
Now let’s implement update functionality:
class TaskDB {
// ... previous code ...
async updateTask(id, updates) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['tasks'], 'readwrite');
const objectStore = transaction.objectStore('tasks');
// First, get the existing task
const getRequest = objectStore.get(id);
getRequest.onsuccess = () => {
const task = getRequest.result;
if (!task) {
reject(new Error('Task not found'));
return;
}
// Merge updates with existing task
const updatedTask = {
...task,
...updates,
updatedAt: new Date().toISOString()
};
const updateRequest = objectStore.put(updatedTask);
updateRequest.onsuccess = () => {
console.log('Task updated successfully');
resolve(updatedTask);
};
updateRequest.onerror = () => {
reject(new Error('Failed to update task'));
};
};
getRequest.onerror = () => {
reject(new Error('Failed to retrieve task for update'));
};
});
}
async markTaskComplete(id) {
return this.updateTask(id, {
status: 'completed',
completedAt: new Date().toISOString()
});
}
async bulkUpdateStatus(taskIds, newStatus) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['tasks'], 'readwrite');
const objectStore = transaction.objectStore('tasks');
const updatedTasks = [];
taskIds.forEach((id) => {
const getRequest = objectStore.get(id);
getRequest.onsuccess = () => {
const task = getRequest.result;
if (task) {
task.status = newStatus;
task.updatedAt = new Date().toISOString();
objectStore.put(task);
updatedTasks.push(task);
}
};
});
transaction.oncomplete = () => {
console.log(`Updated ${updatedTasks.length} tasks`);
resolve(updatedTasks);
};
transaction.onerror = () => {
reject(new Error('Bulk update failed'));
};
});
}
}
// Usage examples
const updated = await taskDB.updateTask(1, {
priority: 'urgent',
description: 'Updated description'
});
console.log('Updated task:', updated);
// Output: { id: 1, title: 'Complete IndexedDB tutorial', priority: 'urgent',
// description: 'Updated description', updatedAt: '2024-01-18T...' }
await taskDB.markTaskComplete(1);
console.log('Task marked as complete');
// Output: Task marked as complete
await taskDB.bulkUpdateStatus([2, 3, 4], 'in-progress');
// Output: Updated 3 tasks
The updateTask method retrieves the existing task, merges it with the updates, and uses put() to save the changes. The put() method either updates an existing record or creates a new one if the key doesn’t exist. The method automatically adds an updatedAt timestamp. markTaskComplete is a convenience method that demonstrates how to create specialized update functions. The bulkUpdateStatus method shows how to efficiently update multiple records within a single transaction, which is much faster than individual updates.
Deleting Tasks
Let’s implement deletion functionality:
class TaskDB {
// ... previous code ...
async deleteTask(id) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['tasks'], 'readwrite');
const objectStore = transaction.objectStore('tasks');
const request = objectStore.delete(id);
request.onsuccess = () => {
console.log('Task deleted successfully');
resolve(true);
};
request.onerror = () => {
reject(new Error('Failed to delete task'));
};
});
}
async deleteCompletedTasks() {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['tasks'], 'readwrite');
const objectStore = transaction.objectStore('tasks');
const index = objectStore.index('status');
const request = index.openCursor(IDBKeyRange.only('completed'));
let deletedCount = 0;
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
cursor.delete();
deletedCount++;
cursor.continue();
} else {
console.log(`Deleted ${deletedCount} completed tasks`);
resolve(deletedCount);
}
};
request.onerror = () => {
reject(new Error('Failed to delete completed tasks'));
};
});
}
async deleteAllTasks() {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['tasks'], 'readwrite');
const objectStore = transaction.objectStore('tasks');
const request = objectStore.clear();
request.onsuccess = () => {
console.log('All tasks deleted');
resolve(true);
};
request.onerror = () => {
reject(new Error('Failed to clear tasks'));
};
});
}
}
// Usage examples
await taskDB.deleteTask(1);
// Output: Task deleted successfully
const deletedCount = await taskDB.deleteCompletedTasks();
console.log(`Removed ${deletedCount} completed tasks`);
// Output: Deleted 5 completed tasks
// Output: Removed 5 completed tasks
await taskDB.deleteAllTasks();
// Output: All tasks deleted
The deleteTask method removes a single task by its ID. If the task doesn’t exist, the operation still succeeds without error. deleteCompletedTasks demonstrates cursor-based deletion, where we iterate through all completed tasks using the status index and delete them one by one. This is useful for batch cleanup operations. The clear() method in deleteAllTasks is the most efficient way to remove all records from an object store, essentially resetting it to empty.
Building the User Interface
Now let’s create a complete HTML interface for our task manager:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline Task Manager</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.status-indicator {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-left: 10px;
}
.status-indicator.online { background: #4ade80; }
.status-indicator.offline { background: #f87171; }
.task-form {
padding: 30px;
border-bottom: 1px solid #e5e7eb;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: 600;
color: #374151;
}
input, textarea, select {
width: 100%;
padding: 10px;
border: 2px solid #e5e7eb;
border-radius: 5px;
font-size: 14px;
transition: border-color 0.3s;
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: #667eea;
}
button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 12px 24px;
border-radius: 5px;
cursor: pointer;
font-weight: 600;
transition: transform 0.2s;
}
button:hover {
transform: translateY(-2px);
}
.filters {
padding: 20px 30px;
background: #f9fafb;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.filter-btn {
padding: 8px 16px;
background: white;
border: 2px solid #e5e7eb;
color: #374151;
font-size: 14px;
}
.filter-btn.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-color: transparent;
}
.tasks-list {
padding: 30px;
}
.task-item {
background: #f9fafb;
padding: 20px;
border-radius: 8px;
margin-bottom: 15px;
border-left: 4px solid #667eea;
transition: transform 0.2s;
}
.task-item:hover {
transform: translateX(5px);
}
.task-item.completed {
opacity: 0.6;
border-left-color: #4ade80;
}
.task-item.high-priority {
border-left-color: #ef4444;
}
.task-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 10px;
}
.task-title {
font-size: 18px;
font-weight: 600;
color: #111827;
}
.task-actions {
display: flex;
gap: 5px;
}
.task-actions button {
padding: 6px 12px;
font-size: 12px;
}
.task-meta {
display: flex;
gap: 15px;
font-size: 14px;
color: #6b7280;
margin-top: 10px;
}
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.badge.high { background: #fee2e2; color: #991b1b; }
.badge.medium { background: #fef3c7; color: #92400e; }
.badge.low { background: #dbeafe; color: #1e40af; }
.badge.completed { background: #d1fae5; color: #065f46; }
.badge.in-progress { background: #e0e7ff; color: #3730a3; }
.badge.pending { background: #f3f4f6; color: #374151; }
.empty-state {
text-align: center;
padding: 60px 20px;
color: #9ca3af;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Offline Task Manager</h1>
<p>
<span id="connection-status">Checking connection...</span>
<span class="status-indicator" id="status-dot"></span>
</p>
</div>
<div class="task-form">
<h2>Add New Task</h2>
<form id="taskForm">
<div class="form-group">
<label for="title">Title</label>
<input type="text" id="title" required>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" rows="3"></textarea>
</div>
<div class="form-group">
<label for="priority">Priority</label>
<select id="priority">
<option value="low">Low</option>
<option value="medium" selected>Medium</option>
<option value="high">High</option>
</select>
</div>
<div class="form-group">
<label for="dueDate">Due Date</label>
<input type="date" id="dueDate" required>
</div>
<button type="submit">Add Task</button>
</form>
</div>
<div class="filters">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="pending">Pending</button>
<button class="filter-btn" data-filter="in-progress">In Progress</button>
<button class="filter-btn" data-filter="completed">Completed</button>
<button class="filter-btn" data-filter="high">High Priority</button>
</div>
<div class="tasks-list" id="tasksList">
<div class="empty-state">No tasks yet. Add one above!</div>
</div>
</div>
<script src="taskdb.js"></script>
<script src="app.js"></script>
</body>
</html>
This HTML creates a beautiful, modern interface with a gradient header showing online/offline status, a form for adding tasks, filter buttons for viewing different task categories, and a container for displaying tasks. The CSS provides smooth animations, color-coded priority levels (red for high, yellow for medium, blue for low), and a responsive layout that works on mobile devices. The interface uses a purple gradient theme and includes visual feedback for user interactions.
Implementing the Application Logic
Now let’s create the JavaScript that powers our interface:
// app.js
class TaskManager {
constructor() {
this.db = new TaskDB();
this.currentFilter = 'all';
this.init();
}
async init() {
try {
await this.db.init();
console.log('Database ready');
this.setupEventListeners();
this.updateConnectionStatus();
this.renderTasks();
// Listen for online/offline events
window.addEventListener('online', () => this.updateConnectionStatus());
window.addEventListener('offline', () => this.updateConnectionStatus());
} catch (error) {
console.error('Initialization failed:', error);
alert('Failed to initialize the application');
}
}
setupEventListeners() {
// Form submission
document.getElementById('taskForm').addEventListener('submit', (e) => {
e.preventDefault();
this.handleAddTask();
});
// Filter buttons
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('.filter-btn').forEach(b =>
b.classList.remove('active')
);
e.target.classList.add('active');
this.currentFilter = e.target.dataset.filter;
this.renderTasks();
});
});
}
updateConnectionStatus() {
const statusText = document.getElementById('connection-status');
const statusDot = document.getElementById('status-dot');
if (navigator.onLine) {
statusText.textContent = 'Online';
statusDot.className = 'status-indicator online';
} else {
statusText.textContent = 'Offline';
statusDot.className = 'status-indicator offline';
}
}
async handleAddTask() {
const title = document.getElementById('title').value;
const description = document.getElementById('description').value;
const priority = document.getElementById('priority').value;
const dueDate = document.getElementById('dueDate').value;
const task = {
title,
description,
priority,
dueDate,
status: 'pending'
};
try {
await this.db.addTask(task);
document.getElementById('taskForm').reset();
this.renderTasks();
} catch (error) {
console.error('Failed to add task:', error);
alert('Failed to add task');
}
}
async renderTasks() {
try {
let tasks = await this.db.getAllTasks();
// Apply filter
switch(this.currentFilter) {
case 'pending':
case 'in-progress':
case 'completed':
tasks = tasks.filter(t => t.status === this.currentFilter);
break;
case 'high':
tasks = tasks.filter(t => t.priority === 'high');
break;
}
const tasksList = document.getElementById('tasksList');
if (tasks.length === 0) {
tasksList.innerHTML = '<div class="empty-state">No tasks found</div>';
return;
}
tasksList.innerHTML = tasks.map(task => `
<div class="task-item ${task.status === 'completed' ? 'completed' : ''}
${task.priority === 'high' ? 'high-priority' : ''}">
<div class="task-header">
<div class="task-title">${this.escapeHtml(task.title)}</div>
<div class="task-actions">
${task.status !== 'completed' ?
`<button onclick="taskManager.updateTaskStatus(${task.id}, 'in-progress')">
Start
</button>
<button onclick="taskManager.updateTaskStatus(${task.id}, 'completed')">
Complete
</button>` : ''}
<button onclick="taskManager.deleteTask(${task.id})">Delete</button>
</div>
</div>
${task.description ? `<p>${this.escapeHtml(task.description)}</p>` : ''}
<div class="task-meta">
<span class="badge ${task.priority}">${task.priority}</span>
<span class="badge ${task.status}">${task.status}</span>
<span>Due: ${task.dueDate}</span>
</div>
</div>
`).join('');
} catch (error) {
console.error('Failed to render tasks:', error);
}
}
async updateTaskStatus(id, status) {
try {
await this.db.updateTask(id, { status });
this.renderTasks();
} catch (error) {
console.error('Failed to update task:', error);
}
}
async deleteTask(id) {
if (confirm('Are you sure you want to delete this task?')) {
try {
await this.db.deleteTask(id);
this.renderTasks();
} catch (error) {
console.error('Failed to delete task:', error);
}
}
}
escapeHtml(text) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return text.replace(/[&<>"']/g, m => map[m]);
}
}
// Initialize the application
const taskManager = new TaskManager();
This code creates a complete application controller that manages the entire lifecycle of our task manager. When initialized, it connects to IndexedDB, sets up event listeners, and displays any existing tasks. The renderTasks method dynamically generates HTML for each task, applying the current filter (all, pending, in-progress, completed, or high priority). The interface updates in real-time as you add, update, or delete tasks. For example, when you click the “Complete” button on a task, it immediately updates the database and re-renders the list with the task now showing a green border and faded appearance. The escapeHtml method prevents XSS attacks by sanitizing user input before displaying it.
Implementing Offline Synchronization
For a production-ready offline-first application, we need synchronization with a backend server:
class SyncManager {
constructor(taskDB) {
this.db = taskDB;
this.syncQueue = [];
this.isSyncing = false;
this.serverUrl = 'https://api.example.com/tasks';
}
async queueChange(operation, data) {
const change = {
id: Date.now(),
operation, // 'create', 'update', 'delete'
data,
timestamp: new Date().toISOString(),
synced: false
};
this.syncQueue.push(change);
await this.saveSyncQueue();
// Try to sync immediately if online
if (navigator.onLine) {
this.sync();
}
}
async saveSyncQueue() {
// Store sync queue in a separate object store
return new Promise((resolve, reject) => {
const transaction = this.db.db.transaction(['syncQueue'], 'readwrite');
const objectStore = transaction.objectStore('syncQueue');
this.syncQueue.forEach(item => {
objectStore.put(item);
});
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(new Error('Failed to save sync queue'));
});
}
async loadSyncQueue() {
return new Promise((resolve, reject) => {
const transaction = this.db.db.transaction(['syncQueue'], 'readonly');
const objectStore = transaction.objectStore('syncQueue');
const request = objectStore.getAll();
request.onsuccess = () => {
this.syncQueue = request.result.filter(item => !item.synced);
resolve(this.syncQueue);
};
request.onerror = () => reject(new Error('Failed to load sync queue'));
});
}
async sync() {
if (this.isSyncing || !navigator.onLine) {
return;
}
this.isSyncing = true;
console.log('Starting sync...');
try {
await this.loadSyncQueue();
for (const change of this.syncQueue) {
try {
await this.syncChange(change);
change.synced = true;
} catch (error) {
console.error('Failed to sync change:', error);
// Continue with next change
}
}
// Remove synced items
this.syncQueue = this.syncQueue.filter(item => !item.synced);
await this.saveSyncQueue();
console.log('Sync completed');
} catch (error) {
console.error('Sync failed:', error);
} finally {
this.isSyncing = false;
}
}
async syncChange(change) {
let response;
switch(change.operation) {
case 'create':
response = await fetch(this.serverUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change.data)
});
break;
case 'update':
response = await fetch(`${this.serverUrl}/${change.data.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change.data)
});
break;
case 'delete':
response = await fetch(`${this.serverUrl}/${change.data.id}`, {
method: 'DELETE'
});
break;
}
if (!response.ok) {
throw new Error(`Server responded with ${response.status}`);
}
return await response.json();
}
}
// Enhanced TaskDB with sync support
class TaskDB {
// ... previous code ...
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = () => reject(new Error('Failed to open database'));
request.onsuccess = (event) => {
this.db = event.target.result;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create tasks object store
if (!db.objectStoreNames.contains('tasks')) {
const objectStore = db.createObjectStore('tasks', {
keyPath: 'id',
autoIncrement: true
});
objectStore.createIndex('status', 'status', { unique: false });
objectStore.createIndex('priority', 'priority', { unique: false });
objectStore.createIndex('dueDate', 'dueDate', { unique: false });
objectStore.createIndex('createdAt', 'createdAt', { unique: false });
}
// Create sync queue object store
if (!db.objectStoreNames.contains('syncQueue')) {
db.createObjectStore('syncQueue', { keyPath: 'id' });
}
};
});
}
}
// Usage
const taskDB = new TaskDB();
await taskDB.init();
const syncManager = new SyncManager(taskDB);
// Modified addTask to queue for sync
async function addTaskWithSync(task) {
const taskId = await taskDB.addTask(task);
await syncManager.queueChange('create', { ...task, id: taskId });
return taskId;
}
// Listen for online event to trigger sync
window.addEventListener('online', () => {
console.log('Connection restored, syncing...');
syncManager.sync();
});
This sync manager implements a queue-based synchronization strategy. When users make changes while offline, those changes are stored in a separate syncQueue object store. Each change includes the operation type (create, update, delete), the data, and a timestamp. When the connection is restored, the sync() method processes the queue sequentially, sending each change to the server via HTTP requests. If a sync fails partway through, unsynced items remain in the queue for the next sync attempt. This ensures that no data is lost even if the user goes offline multiple times. For example, if a user creates 5 tasks while offline, all 5 will be queued and uploaded to the server once they reconnect.
Error Handling and Best Practices
Let’s implement comprehensive error handling and utility functions:
class TaskDB {
// ... previous code ...
async executeTransaction(storeName, mode, callback) {
return new Promise((resolve, reject) => {
try {
const transaction = this.db.transaction([storeName], mode);
const objectStore = transaction.objectStore(storeName);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(new Error('Transaction aborted'));
callback(objectStore);
} catch (error) {
reject(error);
}
});
}
async getStorageEstimate() {
if ('storage' in navigator && 'estimate' in navigator.storage) {
const estimate = await navigator.storage.estimate();
return {
usage: estimate.usage,
quota: estimate.quota,
percentUsed: (estimate.usage / estimate.quota * 100).toFixed(2)
};
}
return null;
}
async exportData() {
try {
const tasks = await this.getAllTasks();
const dataStr = JSON.stringify(tasks, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `tasks-backup-${new Date().toISOString()}.json`;
link.click();
URL.revokeObjectURL(url);
return true;
} catch (error) {
console.error('Export failed:', error);
return false;
}
}
async importData(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const tasks = JSON.parse(e.target.result);
if (!Array.isArray(tasks)) {
throw new Error('Invalid data format');
}
const importedIds = await this.addMultipleTasks(tasks);
resolve(importedIds);
} catch (error) {
reject(error);
}
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsText(file);
});
}
async getStatistics() {
const tasks = await this.getAllTasks();
return {
total: tasks.length,
byStatus: {
pending: tasks.filter(t => t.status === 'pending').length,
inProgress: tasks.filter(t => t.status === 'in-progress').length,
completed: tasks.filter(t => t.status === 'completed').length
},
byPriority: {
high: tasks.filter(t => t.priority === 'high').length,
medium: tasks.filter(t => t.priority === 'medium').length,
low: tasks.filter(t => t.priority === 'low').length
},
overdue: tasks.filter(t => {
return t.dueDate < new Date().toISOString().split('T')[0]
&& t.status !== 'completed';
}).length
};
}
}
// Usage examples
const stats = await taskDB.getStatistics();
console.log('Task Statistics:', stats);
// Output: {
// total: 15,
// byStatus: { pending: 5, inProgress: 7, completed: 3 },
// byPriority: { high: 4, medium: 8, low: 3 },
// overdue: 2
// }
const storage = await taskDB.getStorageEstimate();
console.log(`Using ${storage.percentUsed}% of available storage`);
// Output: Using 0.05% of available storage
await taskDB.exportData();
// Downloads a JSON file with all tasks
These utility methods enhance the robustness of our application. The executeTransaction helper standardizes transaction handling with proper error catching. getStorageEstimate shows how much IndexedDB storage is being used, which is useful for warning users before they hit quota limits. The exportData method creates a downloadable JSON backup of all tasks, while importData allows users to restore from a backup file. The getStatistics method provides a dashboard overview showing task counts by status and priority, plus how many tasks are overdue. These features make the application production-ready and user-friendly.
IndexedDB provides web applications with powerful local storage capabilities that go far beyond what localStorage or cookies can offer. With its support for large datasets, complex querying through indexes, transactional integrity, and asynchronous operations that don’t block the UI, IndexedDB is the ideal solution for building modern progressive web applications that work seamlessly offline.
The offline-first task manager we’ve built demonstrates that with IndexedDB, you can create robust applications that provide an excellent user experience regardless of network conditions. Whether you’re building a note-taking app, an e-commerce platform with offline browsing, a collaborative tool that works in low-connectivity environments, or a progressive web app that feels native, IndexedDB gives you the foundation to make it happen.
Take what you’ve learned here and start building amazing offline-capable applications. The web is becoming increasingly mobile and global, and users need applications that work everywhere, all the time. With IndexedDB in your toolkit, you’re ready to meet that challenge.
IndexedDB Complete Guide was originally published in Client-Side JavaScript on Medium, where people are continuing the conversation by highlighting and responding to this story.