This mini project builds a notes or task app that works offline – local persistence, a service worker shell, and sync when the network returns. Plain HTML, CSS, and ES modules. No framework.
What you are building
A single-page app where users add, edit, complete, and delete notes or tasks. Data saves to IndexedDB immediately. The UI works with no network. When online, optional sync to a simple REST API (or a mocked endpoint) queues changes made offline.
Suggested layout
notes-app/
index.html
manifest.webmanifest
styles.css
offline.html
sw.js
js/
main.js
notes-store.js
notes-view.js
db.js
sync.js
IndexedDB wrapper
// db.js
const DB_NAME = 'notes-db';
const STORE = 'notes';
export function openDb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => {
req.result.createObjectStore(STORE, { keyPath: 'id' });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function getAllNotes(db) {
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readonly');
const store = tx.objectStore(STORE);
const req = store.getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function putNote(db, note) {
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(note);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
Store with persistence
// notes-store.js
export async function createNotesStore(db) {
const notes = await getAllNotes(db);
const listeners = new Set();
const pendingSync = new Set();
function emit() {
listeners.forEach((fn) => fn(getState()));
}
function getState() {
return { notes: [...notes], pendingSync: pendingSync.size };
}
return {
getState,
subscribe(fn) {
listeners.add(fn);
fn(getState());
return () => listeners.delete(fn);
},
async add(text) {
const note = {
id: crypto.randomUUID(),
text: text.trim(),
done: false,
updatedAt: Date.now(),
};
notes.push(note);
await putNote(db, note);
pendingSync.add(note.id);
emit();
return note;
},
async toggle(id) {
const note = notes.find((n) => n.id === id);
if (!note) return;
note.done = !note.done;
note.updatedAt = Date.now();
await putNote(db, note);
pendingSync.add(id);
emit();
},
getPendingIds: () => [...pendingSync],
markSynced(id) {
pendingSync.delete(id);
emit();
},
};
}
View layer
// notes-view.js
export function renderNotes(root, store) {
const form = root.querySelector('[data-note-form]');
const list = root.querySelector('[data-note-list]');
const status = root.querySelector('[data-sync-status]');
form.addEventListener('submit', async (event) => {
event.preventDefault();
const input = form.querySelector('input');
await store.add(input.value);
input.value = '';
});
list.addEventListener('click', (event) => {
const btn = event.target.closest('[data-toggle]');
if (btn) store.toggle(btn.dataset.toggle);
});
store.subscribe(({ notes, pendingSync }) => {
status.textContent = navigator.onLine
? pendingSync ? 'Syncing…' : 'Saved'
: 'Offline - changes stored locally';
list.replaceChildren(
...notes.map((note) => {
const li = document.createElement('li');
li.className = note.done ? 'done' : '';
const span = document.createElement('span');
span.textContent = note.text;
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.dataset.toggle = note.id;
toggle.textContent = note.done ? 'Undo' : 'Done';
li.append(span, toggle);
return li;
})
);
});
}
Background sync (simplified)
// sync.js
export async function flushPending(store) {
if (!navigator.onLine) return;
for (const id of store.getPendingIds()) {
const note = store.getState().notes.find((n) => n.id === id);
if (!note) continue;
try {
await fetch('/api/notes', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(note),
});
store.markSynced(id);
} catch {
break;
}
}
}
window.addEventListener('online', () => flushPending(store));
For a local-only demo, skip the API and drop sync.js – IndexedDB plus the service worker shell still proves offline UX.
Wire-up in main.js
import { openDb } from './db.js';
import { createNotesStore } from './notes-store.js';
import { renderNotes } from './notes-view.js';
import { flushPending } from './sync.js';
const db = await openDb();
const store = await createNotesStore(db);
renderNotes(document.querySelector('#app'), store);
flushPending(store);
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
Checklist
- App shell caches and offline fallback page load with network disabled.
- Notes survive refresh and tab close.
- UI uses
textContent– noinnerHTMLfor user text. - Sync status reflects online/offline and pending queue.
- Manifest allows add-to-home-screen on a test device.
The final tutorial builds a performant data table with virtual scrolling for large lists.

