PWA Task Manager
Ship an installable offline-first task manager with caching, sync, and notifications
Time to implement the project: ~ 4-7 weeks
- HTML
- CSS
- JavaScript
- Progressive Web Apps
- Service Workers
- Caching Strategies
- Background Sync
- Push Notifications
In this advanced project, you will build a task manager that installs like a native app and keeps working without a network connection. Users must create, edit, complete, and delete tasks while offline, then see their changes reconcile when the connection returns. Your app must register a service worker, define a web app manifest, and provide a clear install path so the experience feels like a real product.
You will implement caching strategies for app shell assets and runtime data, then add background sync so offline actions queue and replay safely. The project also includes push notifications for reminders or task updates, requiring permission handling and user-respectful messaging. The end result behaves reliably across sessions, loads fast, handles network transitions cleanly, and demonstrates real-world PWA engineering.
Why This Project Is Considered Advanced
A PWA is not “a website with a badge.” This project forces you to design for failure modes: no network, slow network, partial network, and inconsistent connectivity. You will build an app shell that loads instantly, manage data persistence locally, and then synchronize safely when online. Background sync introduces queue discipline: you must prevent duplicate submissions, handle conflict cases, and surface meaningful UI states such as “pending” or “synced.”
Push notifications add another layer of responsibility. You will implement permission flow, user controls, and notification content that stays clear and relevant. These are the same constraints teams follow in production, and your solution reflects whether you can build trustworthy web apps that behave like installed software.
Prerequisites and Expected Technical Readiness
This project expects comfort with modern JavaScript, browser storage, and asynchronous architecture. You should already build CRUD apps confidently and understand how to debug network behavior and service worker state using DevTools.
- Strong understanding of async JavaScript and promise-based workflows
- Experience with fetch, request/response handling, and error boundaries
- Knowledge of client storage concepts (IndexedDB preferred, localStorage acceptable for small state)
- Understanding of service worker lifecycle, scope, and update behavior
- Ability to reason about offline/online UI states and data consistency
- Familiarity with basic security and permission UX principles
- Comfort working in DevTools (Application tab, Cache Storage, SW debugging)
- Basic API design awareness for syncing tasks (even with a mocked backend)
Non-Negotiable Product Requirements
A credible PWA task manager is evaluated like a product release: installation must work, offline behavior must be reliable, and sync must be predictable. Reviewers look for disciplined caching, safe background replay, and notification handling that respects users. Meeting these requirements demonstrates advanced capability in modern web application engineering.
| Requirement | Explanation | What to Validate |
| Installable app (manifest + icons) | Installation proves you can ship a PWA with correct metadata and a native-like entry point. | “Install” prompt appears and app launches in standalone mode. |
| Service worker with app-shell caching | App-shell caching keeps core UI available offline and improves cold-start performance. | Reload with airplane mode still shows UI instantly. |
| Runtime caching strategy | Separate strategy for API/data prevents stale UI and supports controlled freshness. | Online fetch updates content while offline uses last known data. |
| Offline CRUD for tasks | Offline-first means tasks must be fully usable without network dependency. | Create/edit/complete tasks offline with no UI breakage. |
| Background sync queue | Queued actions replay safely when online, preserving user intent and preventing loss. | Offline actions sync on reconnect without duplicates. |
| Sync conflict and retry rules | Retries and conflict handling keep data consistent under real network instability. | Failed requests retry, errors surface clearly, state stays coherent. |
| Push notifications with permission flow | Notifications require explicit consent and controlled messaging to avoid user harm. | Permission prompts are contextual, notifications fire only when enabled. |
| Update strategy for new versions | Service worker updates must be handled so users do not stay stuck on old code. | New build activates cleanly with user-visible refresh prompt. |
Implementation Strategy Used in Real Teams
Treat offline-first as an architecture constraint, not a feature you bolt on at the end. Start with a task data model that lives locally, then layer sync on top as a transport mechanism. Use IndexedDB for tasks and a dedicated queue store for pending mutations (create/update/delete) with timestamps and unique operation IDs. When the connection returns, process the queue in order, mark operations as completed, and refresh local state from the server or canonical source.
For caching, separate your app shell from runtime requests. A cache-first approach for static assets keeps the UI fast; a network-first or stale-while-revalidate strategy for task data balances correctness and resilience. For push, build a settings surface: users control whether notifications are on, and reminders remain relevant and minimal. When you design for unreliable networks, your app becomes dependable everywhere.
- Use versioned caches and clear naming to avoid “mystery stale assets” after deploys
- Model offline mutations as queue items with operation IDs to prevent double-sync bugs
- Keep UI states explicit: synced, pending, failed, retrying—users trust clarity over silence
- Implement a reconnect trigger (online event) to resume sync immediately without manual refresh
- Use a conservative notification strategy: fewer, higher-signal alerts outperform noisy reminders
- Expose an “Update available” banner when a new service worker activates to reduce confusion
- Test on throttled networks and airplane mode; production PWAs are judged by edge cases
- Log sync outcomes during development to verify queue behavior and failure handling
- Design with secure defaults: request permissions only after user intent is obvious
Common Mistakes When Building a PWA Task Manager
1. Treating the service worker as a simple cache file
A service worker is not just a place where you put a few files into cache. In a real PWA Task Manager, it controls how the app behaves when the network is fast, slow, unavailable, or unstable. A common mistake is caching everything with the same strategy. Static assets, HTML routes, API responses, icons, and task data should not all be handled the same way.
Problematic approach:
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
return cachedResponse || fetch(event.request);
})
);
});
This looks useful, but it is too generic. It may serve stale API data, fail on navigation requests, and make debugging difficult because every request goes through the same logic.
Better approach:
const STATIC_CACHE = "task-manager-static-v1";
const DATA_CACHE = "task-manager-data-v1";
self.addEventListener("fetch", (event) => {
const requestUrl = new URL(event.request.url);
if (event.request.mode === "navigate") {
event.respondWith(handleNavigationRequest(event.request));
return;
}
if (requestUrl.pathname.startsWith("/api/tasks")) {
event.respondWith(networkFirstForTasks(event.request));
return;
}
event.respondWith(cacheFirstForStaticAssets(event.request));
});
async function cacheFirstForStaticAssets(request) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
const networkResponse = await fetch(request);
const cache = await caches.open(STATIC_CACHE);
cache.put(request, networkResponse.clone());
return networkResponse;
}
async function networkFirstForTasks(request) {
const cache = await caches.open(DATA_CACHE);
try {
const networkResponse = await fetch(request);
cache.put(request, networkResponse.clone());
return networkResponse;
} catch (error) {
const cachedResponse = await cache.match(request);
return cachedResponse || new Response(JSON.stringify([]), {
headers: { "Content-Type": "application/json" }
});
}
}
Pay attention to: Use different caching strategies for different request types. App shell assets are usually good candidates for cache-first. Task data often needs network-first or stale-while-revalidate logic so the UI does not silently show outdated information forever.
2. Using localStorage as the main offline database
localStorage is tempting because it is simple, but it is not a strong foundation for an offline-first task manager. It is synchronous, limited, blocks the
main thread, and becomes awkward when you need indexes, queue records, sync statuses, timestamps, retries, or larger task payloads. For serious offline behavior,
IndexedDB is a better choice.
Problematic code:
function saveTask(task) {
const tasks = JSON.parse(localStorage.getItem("tasks")) || [];
tasks.push(task);
localStorage.setItem("tasks", JSON.stringify(tasks));
}
function getTasks() {
return JSON.parse(localStorage.getItem("tasks")) || [];
}
This works for a small demo, but it does not scale well. It also mixes saved tasks, pending operations, sync state, and failed requests into one fragile storage approach.
Better approach:
async function openTaskDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open("pwa-task-manager", 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains("tasks")) {
db.createObjectStore("tasks", { keyPath: "id" });
}
if (!db.objectStoreNames.contains("syncQueue")) {
db.createObjectStore("syncQueue", { keyPath: "operationId" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function saveTaskOffline(task) {
const db = await openTaskDatabase();
const transaction = db.transaction("tasks", "readwrite");
const store = transaction.objectStore("tasks");
store.put({
...task,
syncStatus: "pending",
updatedAt: Date.now()
});
}
Pay attention to: Keep task records and sync queue records separate. Tasks describe the current UI state. Queue records describe what still needs to be sent to the server when the connection returns.
3. Syncing offline actions without unique operation IDs
Offline-first apps must replay user actions safely. If a user creates a task offline, edits it twice, completes it, and then reconnects, the app needs to know exactly which operations are waiting. A common mistake is sending the current task list to the server without tracking individual mutations. This can create duplicates, lost edits, and unpredictable conflict behavior.
Problematic approach:
async function syncTasks() {
const tasks = await getAllTasksFromIndexedDB();
await fetch("/api/tasks/sync", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(tasks)
});
}
This sends the whole local task state, but it does not explain what happened. The server cannot reliably distinguish between created tasks, edited tasks, deleted tasks, and tasks that were already synced earlier.
Better approach:
function createQueueItem(type, task) {
return {
operationId: crypto.randomUUID(),
type,
taskId: task.id,
payload: task,
createdAt: Date.now(),
attempts: 0,
status: "pending"
};
}
async function queueTaskCreate(task) {
await saveTaskOffline({
...task,
syncStatus: "pending"
});
await saveQueueItem(createQueueItem("TASK_CREATE", task));
}
async function processSyncQueue() {
const queueItems = await getPendingQueueItems();
for (const item of queueItems) {
try {
await fetch("/api/sync", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": item.operationId
},
body: JSON.stringify(item)
});
await markQueueItemAsSynced(item.operationId);
await markTaskAsSynced(item.taskId);
} catch (error) {
await increaseQueueAttemptCount(item.operationId);
}
}
}
Pay attention to: Every offline mutation should have a unique operation ID. This helps prevent duplicate creates, supports retries, and gives you a clear way to debug what happened during reconnect.
4. Asking for notification permission too early
Push notifications can make a task manager more useful, but they can also make it feel intrusive. A frequent mistake is asking for notification permission immediately when the app loads. Users are more likely to deny the request because they do not yet understand why the app needs notifications.
Problematic code:
window.addEventListener("load", async () => {
const permission = await Notification.requestPermission();
if (permission === "granted") {
subscribeUserToPush();
}
});
This creates a poor permission flow. The user has not created a task, set a reminder, or shown any intent to receive alerts.
Better approach:
async function enableTaskReminders() {
if (!("Notification" in window)) {
showMessage("Notifications are not supported in this browser.");
return;
}
if (Notification.permission === "denied") {
showMessage("Notifications are blocked. You can enable them in browser settings.");
return;
}
const permission = await Notification.requestPermission();
if (permission !== "granted") {
showMessage("Reminders are turned off.");
return;
}
await subscribeUserToPush();
await saveNotificationPreference(true);
showMessage("Task reminders are now enabled.");
}
Better UI trigger:
<button type="button" onclick="enableTaskReminders()">
Enable reminders for my tasks
</button>
Pay attention to: Ask for notification permission only after a meaningful user action. Add a settings control so users can turn reminders on or off. A good PWA respects the user instead of treating push notifications as a growth trick.
5. Forgetting service worker updates and cache cleanup
Service workers can make your app fast, but they can also trap users on old code if you do not handle updates carefully. A common mistake is creating versioned caches but never deleting old ones, or releasing a new build without giving users a clear way to refresh the app. This leads to confusing bugs where HTML, JavaScript, CSS, and cached data come from different app versions.
Problematic code:
const CACHE_NAME = "task-manager-cache";
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll([
"/",
"/index.html",
"/app.js",
"/styles.css"
]);
})
);
});
This cache name never changes. If files change, the browser may continue serving older cached assets longer than expected.
Better approach:
const STATIC_CACHE = "task-manager-static-v3";
const ALLOWED_CACHES = [STATIC_CACHE, "task-manager-data-v1"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => {
return cache.addAll([
"/",
"/index.html",
"/app.js",
"/styles.css",
"/manifest.webmanifest"
]);
})
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (!ALLOWED_CACHES.includes(cacheName)) {
return caches.delete(cacheName);
}
})
);
})
);
});
Update prompt example:
navigator.serviceWorker.addEventListener("controllerchange", () => {
showUpdateBanner({
message: "A new version is available.",
actionText: "Reload",
onAction: () => window.location.reload()
});
});
Pay attention to: Version your caches, clean old caches during activation, and show an update message when a new service worker is ready. PWA reliability depends not only on offline support, but also on predictable updates.
By completing this project, you'll gain advanced experience building an installable offline-first application with a service worker, deliberate caching strategies, a background sync queue, and push notifications that respect user control. You will strengthen your ability to engineer resilient web apps that handle unreliable networks, manage local and remote state consistently, and ship features that align with real production standards. This foundation prepares you for complex frontend roles involving performance, reliability, and modern app delivery.
Reference Implementations Worth Studying
Beginner-friendly PWA task example:
nico-martin - ToDo PWA
This repository is the best learning-friendly reference for a PWA Task Manager because it keeps the product idea simple: a todo app that demonstrates real progressive web app features. The project includes versions built with Vue and Preact, uses a basic Webpack setup, applies Tailwind CSS for styling, stores todo items in IndexedDB, generates a web app manifest, and uses Workbox with a custom service worker setup.
Pay particular attention to:
- How a simple todo product becomes more advanced when offline storage and service worker behavior are added.
- How IndexedDB is used for persistent task data instead of relying only on temporary state.
- How the manifest and install flow help the app feel closer to native software.
- How the custom add-to-homescreen button improves the installation experience.
- How notification-related features are connected to individual todo reminders.
What makes this implementation useful is its balance between approachable product scope and serious PWA concepts. Use it to understand how offline persistence, installability, and task reminders can fit into a small but meaningful app.
More advanced service worker reference:
judescripts - Advanced PWA Strategies
This implementation is especially useful for studying the infrastructure side of the project. It is an educational PWA example focused on advanced service worker strategies, including cache-first behavior for static assets, network-first behavior for API data, stale-while-revalidate, offline support, background sync, push notifications, an offline fallback page, and a small Node.js server.
When studying the code, focus on:
- How different caching strategies are separated instead of using one generic fetch handler for everything.
- How the offline fallback page supports a more graceful experience when navigation fails.
- How IndexedDB is used to hold form data before background sync sends it later.
- How the service worker listens for sync events and replays pending data after reconnect.
- How push notifications are connected to server-side behavior rather than being treated as a purely frontend feature.
Use this repository as a technical reference for the hardest parts of the PWA Task Manager. It is less about task UI polish and more about understanding the service worker decisions that make offline-first behavior reliable.
Alternative Angular implementation:
johnpapa - PWA Angular
This repository is a strong alternative reference because it shows PWA architecture through an Angular application. It is useful for comparing different approaches to the same PWA problems: online-only behavior, generated Workbox service workers, manual service worker logic, injected Workbox precaching, runtime caching, offline fallback, background sync, and push notification subscription.
While reviewing this project, examine:
- How different branches demonstrate different levels of PWA complexity.
- How Workbox-generated service workers compare with manually written service worker logic.
- How app shell precaching, runtime API caching, and offline fallback are handled separately.
- How background sync stores failed messages in IndexedDB and retries them after the connection returns.
- How push subscription logic changes the architecture because it requires service worker and server coordination.
This implementation is valuable because it helps you think beyond one framework. Even if your own PWA Task Manager is built with vanilla JavaScript, React, Vue, or Svelte, the project shows how professional PWA concerns can be organized in a larger application structure.