Build an Offline Task Tracker PWA

Build a PWA task tracker that installs on desktop and works offline.

Introduction

30 Second Summary

Think about the last time your internet dropped and a web app became completely useless. Progressive Web Apps solve this by making web apps work offline and feel like native desktop apps.

In this project, you will build a task tracker that works without an internet connection and installs on your desktop like a native app. You'll write a service worker from scratch and configure a web app manifest to make it all happen.

What You'll Build

A task tracker web app that lets you add, complete, and delete tasks, works completely offline after the first visit, and installs as a standalone desktop application.

By the end of this project, you'll have:

  • A responsive task tracker UI built with HTML, CSS, and JavaScript that persists data in localStorage.
  • A web app manifest that makes your app installable with a custom name and icon.
  • A hand-written service worker implementing a Cache-First strategy for full offline support.
  • Secret Mission: Build a custom in-app install button using the beforeinstallprompt event.

Are there any prerequisites?

You should be comfortable with HTML, CSS, and JavaScript fundamentals. No prior experience with PWAs or service workers is needed.

Set Up the Project and Local Server

Before you can build a PWA, you need a project folder with the right file structure and a local web server to test it.

In this step, you will create the project folder, set up the files your app needs, install a local server, and add placeholder icons. By the end, you will have a working development environment ready for building.

In this step, get ready to:
  • Create the project folder and all required files.
  • Install and start a local static file server.
  • Add placeholder PWA icons for the app.
Create the project folder structure

Your PWA needs several files working together: an HTML page, a stylesheet, application logic, a service worker, and a manifest. You will create all of these as empty files now and fill them in during later steps.

  • Open VS Code's integrated terminal by pressing Ctrl+` (backtick key, works on both macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir task-tracker-pwa
cd task-tracker-pwa
  • Create the project files and the icons subfolder by running the commands in the tab that matches your operating system:

macOS

touch index.html style.css app.js sw.js manifest.json
mkdir icons

Windows

New-Item index.html, style.css, app.js, sw.js, manifest.json -ItemType File
mkdir icons
  • Confirm the files exist by running:
ls

You should see index.html, style.css, app.js, sw.js, manifest.json, and the icons folder listed in the output.

Don't see all the files?

Make sure you ran the commands from inside the task-tracker-pwa folder. Run pwd (macOS) or Get-Location (Windows) to check your current directory ends with task-tracker-pwa.

help me find where my project files were created

Install and run the local dev server

Service workers only register on secure origins. Browsers treat localhost as a secure origin, so a local server is all you need for development.

  • Install the serve package globally by running this command:
npm install --global serve

What does this command do?

This installs serve, a lightweight static file server from Vercel, as a global Node.js package. Global packages are available from any folder on your machine, not just this project.

You should see output ending with a line like added 1 package confirming the install succeeded.

Seeing a permissions error?

On macOS, you may need to prefix the command with sudo (e.g. sudo npm install --global serve) and enter your password. On Windows, try closing VS Code and reopening it by right-clicking and selecting Run as administrator.

help me fix npm global install permissions

  • Start the local server by running:
npx serve

The terminal shows a message with the local address. Your server is now running at http://localhost:3000.

Why is the terminal stuck?

The serve process keeps running to respond to browser requests. This is normal. Leave this terminal tab open and use a new terminal tab for any future commands.

To open a new terminal tab in VS Code, click the + icon in the terminal panel header.

  • Open Google Chrome.
  • Type http://localhost:3000 in the address bar and press Enter.

You should see a blank white page. This is correct because your index.html file is empty.

Seeing a connection error?

Make sure the serve process is still running in your first terminal tab. If you accidentally closed it, run npx serve again from inside the task-tracker-pwa folder.

Also confirm you typed the URL exactly as shown, including http:// at the start.

help me troubleshoot my local server connection

Generate placeholder PWA icons

Every installable PWA needs at least two icon sizes: 192x192 pixels and 512x512 pixels. These appear on the home screen, taskbar, and splash screen when the app launches.

  • Create a simple square image in any image editor. A solid color square works perfectly for now.
  • Save two versions: one at 192x192 pixels and one at 512x512 pixels.
  • Name them icon-192x192.png and icon-512x512.png.
  • Place both files inside the icons folder in your task-tracker-pwa directory.

Quick icon options

On macOS, open Preview and create a new image with File > New from Clipboard after copying a colored square. On Windows, open Paint, resize the canvas to 192x192, fill it with a color, and export as PNG.

Alternatively, search for "PWA icon generator" online and upload any small image to get correctly sized PNGs.

  • Confirm the icons are in place by opening a new terminal tab in VS Code and running:
ls icons

You should see icon-192x192.png and icon-512x512.png listed.

Icons folder is empty?

Double-check that you saved the files directly inside the icons subfolder, not in the parent task-tracker-pwa folder. File names must match exactly, including the lowercase spelling and the .png extension.

help me find where my icon files ended up

Your project structure is ready and the local server is running. Next up, you will build the task tracker UI and bring it to life with JavaScript.

Build the Task Tracker UI and Logic

Your project folder and local server are ready to go. Now it is time to build the actual task tracker application.

In this step, you will create the full user interface and wire up the JavaScript logic that lets users add, complete, and delete tasks. The tasks will persist in the browser's localStorage so they survive page refreshes.

In this step, get ready to:
  • Build the HTML page structure with a task input and list container.
  • Style the app with a clean, responsive card-based layout.
  • Implement the task logic (add, complete, delete) with localStorage persistence.
Build the HTML structure

The HTML file defines the layout of your task tracker. It includes a header, an input field with an add button, and an empty list where tasks will render dynamically.

  • In VS Code, open index.html from the task-tracker-pwa folder and paste in the following code:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="theme-color" content="#2196F3">
  <title>Task Tracker PWA</title>
  <link rel="manifest" href="manifest.json">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="container">
    <header>
      <h1>Task Tracker</h1>
    </header>
    <div class="input-section">
      <input type="text" id="task-input" placeholder="Add a new task..." autocomplete="off">
      <button id="add-btn">Add</button>
    </div>
    <ul id="task-list"></ul>
  </div>
  <script src="app.js"></script>
</body>
</html>

What does this code do?

This HTML sets up the skeleton of your task tracker app.

  • The theme-color meta tag tells the browser what color to use for the title bar when the app is installed as a PWA.
  • The <link rel="manifest"> tag connects the web app manifest (you will write this in the next step).
  • The #task-input and #add-btn elements are the controls for adding tasks.
  • The #task-list is an empty <ul> that JavaScript will fill with task items dynamically.
  • Save index.html.
  • Switch to Chrome and refresh http://localhost:3000. You should see the text "Task Tracker" and an input field with an "Add" button (unstyled).

Still seeing a directory listing?

Make sure you saved the file as index.html (not Index.html or index.htm). The server looks for index.html specifically.

Also confirm the file is saved inside the task-tracker-pwa folder (not a subfolder). Help me debug why my page is not loading.

Style the app

A clean visual design makes the app feel like a real product. You will add styles for the page layout, the input area, and each task card.

  • Open style.css and paste in the following styles for the page layout and input section:
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background-color: #f5f5f5;
  min-height: 100vh;
  padding: 20px;
}

.container {
  max-width: 500px;
  margin: 0 auto;
}

header {
  text-align: center;
  margin-bottom: 30px;
}

header h1 {
  color: #2196F3;
  font-size: 2rem;
}

.input-section {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}

#task-input {
  flex: 1;
  padding: 12px 16px;
  border: 2px solid #e0e0e0;
  border-radius: 8px;
  font-size: 1rem;
  outline: none;
  transition: border-color 0.2s;
}

#task-input:focus {
  border-color: #2196F3;
}

#add-btn {
  padding: 12px 24px;
  background-color: #2196F3;
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 1rem;
  cursor: pointer;
  transition: background-color 0.2s;
}

#add-btn:hover {
  background-color: #1976D2;
}

#task-list {
  list-style: none;
}

What does this code do?

These styles create the page structure. The reset (*) removes default browser margins so everything looks consistent. The .container centers the app at 500px wide.

The input section uses display: flex so the text field stretches to fill available space while the button keeps a fixed width. The blue color scheme (#2196F3) gives the app a polished Material Design feel.

  • Now add the task item styles below the code you just pasted in style.css:
.task-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 14px 16px;
  background-color: white;
  border-radius: 8px;
  margin-bottom: 10px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
  transition: transform 0.1s;
}

.task-item:hover {
  transform: translateY(-1px);
}

.task-item.completed .task-text {
  text-decoration: line-through;
  color: #9e9e9e;
}

.task-text {
  flex: 1;
  font-size: 1rem;
  color: #333;
  cursor: pointer;
}

.delete-btn {
  padding: 6px 12px;
  background-color: #ff5252;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 0.85rem;
  cursor: pointer;
  transition: background-color 0.2s;
}

.delete-btn:hover {
  background-color: #d32f2f;
}

What does this code do?

Each .task-item is a white card with a subtle shadow and a lift effect on hover. The .completed class adds a strikethrough to mark tasks as done.

The .task-text uses flex: 1 to take up all remaining space, pushing the red delete button to the right edge of the card.

  • Save style.css and refresh Chrome.
  • You should see a centered blue "Task Tracker" heading, a rounded input field, and a blue "Add" button arranged side by side.

Styles not showing up?

Confirm that your index.html has <link rel="stylesheet" href="style.css"> in the <head>. Also check that style.css is saved in the same folder as index.html. Help me fix my CSS not loading.

Implement the task logic

The JavaScript file handles all the interactivity. It reads and writes tasks to localStorage so they persist between browser sessions.

  • Open app.js and paste in the data layer (DOM references, task storage, and the render function):
const taskInput = document.getElementById("task-input");
const addBtn = document.getElementById("add-btn");
const taskList = document.getElementById("task-list");

let tasks = JSON.parse(localStorage.getItem("tasks")) || [];

function saveTasks() {
  localStorage.setItem("tasks", JSON.stringify(tasks));
}

function renderTasks() {
  taskList.innerHTML = "";
  tasks.forEach((task, index) => {
    const li = document.createElement("li");
    li.classList.add("task-item");
    if (task.completed) {
      li.classList.add("completed");
    }

    const span = document.createElement("span");
    span.classList.add("task-text");
    span.textContent = task.text;
    span.addEventListener("click", () => toggleTask(index));

    const deleteBtn = document.createElement("button");
    deleteBtn.classList.add("delete-btn");
    deleteBtn.textContent = "Delete";
    deleteBtn.addEventListener("click", () => deleteTask(index));

    li.appendChild(span);
    li.appendChild(deleteBtn);
    taskList.appendChild(li);
  });
}

What does this code do?

The first three lines grab references to the HTML elements your code will interact with. The tasks array loads from localStorage on page load (or starts empty if nothing is saved yet).

The renderTasks() function clears the task list, then rebuilds it by looping through the tasks array. Each task becomes a <li> with a clickable text span (to toggle completion) and a delete button.

  • Below the code you just pasted, add the action functions, event listeners, and initial render call:
function addTask() {
  const text = taskInput.value.trim();
  if (text === "") return;
  tasks.push({ text: text, completed: false });
  saveTasks();
  renderTasks();
  taskInput.value = "";
}

function toggleTask(index) {
  tasks[index].completed = !tasks[index].completed;
  saveTasks();
  renderTasks();
}

function deleteTask(index) {
  tasks.splice(index, 1);
  saveTasks();
  renderTasks();
}

addBtn.addEventListener("click", addTask);
taskInput.addEventListener("keypress", (e) => {
  if (e.key === "Enter") addTask();
});

renderTasks();

What does this code do?

The three functions handle task actions. addTask() pushes a new task object into the array and clears the input. toggleTask() flips the completed flag. deleteTask() removes the task from the array.

Every action calls saveTasks() then renderTasks() to persist the change and update the UI. The event listeners wire up the Add button and the Enter key. The final renderTasks() call displays any tasks already saved in localStorage when the page loads.

  • Finally, add the Service Worker registration block at the bottom of app.js:
// Register service worker
if ("serviceWorker" in navigator) {
  navigator.serviceWorker
    .register("/sw.js")
    .then((registration) => {
      console.log("Service Worker registered with scope:", registration.scope);
    })
    .catch((error) => {
      console.error("Service Worker registration failed:", error);
    });
}

What does this code do?

This checks whether the browser supports service workers, then registers sw.js as your app's service worker. The service worker file is still empty for now. You will write the caching logic in a later step.

Registration happens here (in the main app script) because the service worker needs to be registered after the page loads. The .then() logs a success message to the console so you can confirm it worked.

  • Save app.js and refresh Chrome.
  • Type a task like Buy groceries into the input field and click Add. You should see a white task card appear below.
  • Click the task text to toggle it as completed (strikethrough appears).
  • Click the red Delete button to remove it.
  • Add another task, then refresh the page. The task should still be there (loaded from localStorage).

Tasks not appearing when you click Add?

Open Chrome DevTools with Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows) and check the Console tab for errors. A common cause is a typo in an element ID. Make sure task-input, add-btn, and task-list in your JavaScript match the IDs in your HTML exactly.

Help me debug why my tasks are not rendering.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for each file at the end of this step.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="theme-color" content="#2196F3">
  <title>Task Tracker PWA</title>
  <link rel="manifest" href="manifest.json">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="container">
    <header>
      <h1>Task Tracker</h1>
    </header>
    <div class="input-section">
      <input type="text" id="task-input" placeholder="Add a new task..." autocomplete="off">
      <button id="add-btn">Add</button>
    </div>
    <ul id="task-list"></ul>
  </div>
  <script src="app.js"></script>
</body>
</html>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background-color: #f5f5f5;
  min-height: 100vh;
  padding: 20px;
}

.container {
  max-width: 500px;
  margin: 0 auto;
}

header {
  text-align: center;
  margin-bottom: 30px;
}

header h1 {
  color: #2196F3;
  font-size: 2rem;
}

.input-section {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}

#task-input {
  flex: 1;
  padding: 12px 16px;
  border: 2px solid #e0e0e0;
  border-radius: 8px;
  font-size: 1rem;
  outline: none;
  transition: border-color 0.2s;
}

#task-input:focus {
  border-color: #2196F3;
}

#add-btn {
  padding: 12px 24px;
  background-color: #2196F3;
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 1rem;
  cursor: pointer;
  transition: background-color 0.2s;
}

#add-btn:hover {
  background-color: #1976D2;
}

#task-list {
  list-style: none;
}

.task-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 14px 16px;
  background-color: white;
  border-radius: 8px;
  margin-bottom: 10px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
  transition: transform 0.1s;
}

.task-item:hover {
  transform: translateY(-1px);
}

.task-item.completed .task-text {
  text-decoration: line-through;
  color: #9e9e9e;
}

.task-text {
  flex: 1;
  font-size: 1rem;
  color: #333;
  cursor: pointer;
}

.delete-btn {
  padding: 6px 12px;
  background-color: #ff5252;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 0.85rem;
  cursor: pointer;
  transition: background-color 0.2s;
}

.delete-btn:hover {
  background-color: #d32f2f;
}
const taskInput = document.getElementById("task-input");
const addBtn = document.getElementById("add-btn");
const taskList = document.getElementById("task-list");

let tasks = JSON.parse(localStorage.getItem("tasks")) || [];

function saveTasks() {
  localStorage.setItem("tasks", JSON.stringify(tasks));
}

function renderTasks() {
  taskList.innerHTML = "";
  tasks.forEach((task, index) => {
    const li = document.createElement("li");
    li.classList.add("task-item");
    if (task.completed) {
      li.classList.add("completed");
    }

    const span = document.createElement("span");
    span.classList.add("task-text");
    span.textContent = task.text;
    span.addEventListener("click", () => toggleTask(index));

    const deleteBtn = document.createElement("button");
    deleteBtn.classList.add("delete-btn");
    deleteBtn.textContent = "Delete";
    deleteBtn.addEventListener("click", () => deleteTask(index));

    li.appendChild(span);
    li.appendChild(deleteBtn);
    taskList.appendChild(li);
  });
}

function addTask() {
  const text = taskInput.value.trim();
  if (text === "") return;
  tasks.push({ text: text, completed: false });
  saveTasks();
  renderTasks();
  taskInput.value = "";
}

function toggleTask(index) {
  tasks[index].completed = !tasks[index].completed;
  saveTasks();
  renderTasks();
}

function deleteTask(index) {
  tasks.splice(index, 1);
  saveTasks();
  renderTasks();
}

addBtn.addEventListener("click", addTask);
taskInput.addEventListener("keypress", (e) => {
  if (e.key === "Enter") addTask();
});

renderTasks();

// Register service worker
if ("serviceWorker" in navigator) {
  navigator.serviceWorker
    .register("/sw.js")
    .then((registration) => {
      console.log("Service Worker registered with scope:", registration.scope);
    })
    .catch((error) => {
      console.error("Service Worker registration failed:", error);
    });
}

Your task tracker now works as a fully functional web app. Next up, you will create the web app manifest that tells the browser your app is installable as a PWA.

Create the Web App Manifest

Your task tracker works perfectly in a browser tab. But a PWA goes further. It installs on your desktop and looks like a native app with its own window.

The key to making a web app installable is the web app manifest. This is a JSON file that tells the browser your app's name, icons, colors, and how to display it. In this step, you will write the manifest and verify Chrome recognizes your app as installable.

In this step, get ready to:
  • Write the manifest.json with all required PWA fields.
  • Verify the manifest in Chrome DevTools and confirm installability.
Write the manifest with required fields

The manifest is what transforms a regular website into an installable app. Chrome checks this file to decide whether to offer the install prompt.

  • In VS Code, open manifest.json (currently empty) and paste the following code:
{
  "name": "Task Tracker PWA",
  "short_name": "Tasks",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#f5f5f5",
  "theme_color": "#2196F3",
  "icons": [
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

What does each field do?

  • name is the full app name shown during installation and on the splash screen.
  • short_name is what appears under the app icon on your desktop or home screen.
  • start_url tells the browser which page to open when the app launches.
  • display set to standalone means the app opens in its own window with no browser address bar.
  • icons provides the 192x192 and 512x512 PNG icons Chrome requires for installation.
  • Save manifest.json.

JSON syntax errors?

JSON is strict about formatting. Make sure every string is wrapped in double quotes, every key-value pair except the last in an object has a trailing comma, and there are no trailing commas after the last item.

help me fix a JSON syntax error in my manifest.json

Verify the manifest in Chrome DevTools

Your index.html already links to the manifest with <link rel="manifest" href="manifest.json"> in the <head>. Chrome reads this link to discover your manifest. Now you can confirm it works.

  • Switch to Chrome at http://localhost:3000.
  • Open DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
  • Click the Application tab in the DevTools panel.
  • In the left sidebar, click Manifest.

You should see all your manifest fields listed. The Identity section shows your app name. The Icons section displays your two PNG icons.

  • Check that there are no errors or warnings displayed at the top of the Manifest panel.
  • Scroll down to the Installability section and confirm it shows the app is installable.

What makes a PWA installable?

Chrome requires a manifest with a name or short_name, icons at 192px and 512px, a start_url, and a display mode. Your manifest provides all of these.

The service worker is not strictly required for the install prompt in modern Chrome. But you will write one in the next step because offline capability is what makes your app truly reliable.

Manifest panel shows nothing?

Try a hard refresh with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows). The browser may have cached the empty manifest from earlier.

Also confirm your manifest.json file is saved and sits in the same folder as index.html (the project root, not inside a subfolder).

help me debug why Chrome DevTools is not showing my manifest

✔️ Awesome, I've got everything!

Great. Double check you have saved manifest.json and that DevTools shows no errors.

ⓧ I'd like to double check the full code

Here is the complete manifest.json file:

{
  "name": "Task Tracker PWA",
  "short_name": "Tasks",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#f5f5f5",
  "theme_color": "#2196F3",
  "icons": [
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

Chrome now recognizes your app as installable. Next up, you will write the service worker that caches your app's assets and makes it work completely offline.

Write the Service Worker

Your manifest is set up and Chrome recognizes your app as installable. Now it's time to make the app work offline.

A Service Worker is a JavaScript file that runs in the background, separate from your web page. It intercepts every network request your app makes and decides whether to serve files from a local cache or fetch them from the network. This is what gives PWAs their offline superpowers.

In this step, get ready to:
  • Define which files to cache for offline use.
  • Write the install, activate, and fetch event handlers in your service worker.
  • Implement the Cache-First strategy so your app loads from cache before hitting the network.
Implement the install event in sw.js

The service worker lifecycle has three key events: install, activate, and fetch. You'll handle each one to build a complete offline caching strategy.

  • In VS Code, open sw.js from the Explorer sidebar.
  • Define the cache name and the list of assets to precache by pasting this code:
const CACHE_NAME = "task-tracker-v1";
const ASSETS_TO_CACHE = [
  "/",
  "/index.html",
  "/style.css",
  "/app.js",
  "/manifest.json",
  "/icons/icon-192x192.png",
  "/icons/icon-512x512.png"
];

What does this code do?

CACHE_NAME is a version label for your cache. When you update your app later, you'll change this name to bust the old cache.

ASSETS_TO_CACHE lists every file your app needs to work offline. The service worker will download and store all of these during installation.

You won't see a change yet. The next chunk uses these constants to actually populate the cache.

  • Add the install event handler below the ASSETS_TO_CACHE array by pasting this code:
// Install event - precache assets
self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      console.log("Caching app assets");
      return cache.addAll(ASSETS_TO_CACHE);
    })
  );
});

What does this code do?

The install event fires once when the browser first registers your service worker. This is the perfect moment to download and store all your app's files.

event.waitUntil() tells the browser not to mark the service worker as "installed" until all the caching is complete. caches.open() creates a named cache, and cache.addAll() fetches and stores every URL in the array.

  • Save sw.js.
  • Switch to Chrome and refresh the page at http://localhost:3000.
  • Open DevTools with Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
  • Go to the Application tab, then click Service Workers in the left sidebar. You should see your service worker listed with a status of "activated and running".

Service worker not showing up?

Make sure your sw.js file is at the root of your project folder (next to index.html), not inside a subfolder. The service worker's scope is determined by its file location.

Check the Console tab for any red error messages. A typo in the ASSETS_TO_CACHE array (a mismatched file path) will cause the entire install to fail silently.

Help me debug my service worker registration issue.

Implement the activate event

The activate event fires after a new service worker installs and takes control. It's the ideal place to clean up old caches from previous versions of your app.

  • In sw.js, add the activate event handler below the install event by pasting this code:
// Activate event - clean up old caches
self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME)
          .map((name) => caches.delete(name))
      );
    })
  );
});

What does this code do?

caches.keys() returns all cache names stored in the browser. The code filters out any cache that does not match the current CACHE_NAME and deletes those stale caches.

This matters when you deploy updates. If you change CACHE_NAME to task-tracker-v2 later, the old task-tracker-v1 cache gets cleaned up automatically.

You won't see a visible change yet. The activate event only does work when an old cache exists to clean up. The next chunk adds the fetch handler that makes the cache usable.

Implement the fetch event with Cache-First strategy

The fetch event is where the magic happens. Every time your app requests a file (HTML, CSS, JS, images), the service worker intercepts that request and decides where to get the response from.

What is Cache-First strategy?

Cache-First means the service worker checks the local cache before asking the network. If the file is already cached, it's served instantly. If not, it falls back to a normal network request.

This makes your app load extremely fast on repeat visits and ensures it works completely offline after the first load.

  • Add the fetch event handler below the activate event in sw.js by pasting this code:
// Fetch event - Cache-First strategy
self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((cachedResponse) => {
      if (cachedResponse) {
        return cachedResponse;
      }
      return fetch(event.request);
    })
  );
});

What does this code do?

event.respondWith() takes over the browser's default network behavior. Instead of going straight to the server, it runs your caching logic first.

caches.match(event.request) looks for a cached copy of the requested file. If one exists, it's returned immediately. If the cache misses, fetch(event.request) sends the request to the network as usual.

  • Save sw.js.
  • Return to Chrome and hard refresh with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows) to force the new service worker to install.
  • In DevTools, go to Application > Cache Storage in the left sidebar. Click on task-tracker-v1. You should see all seven precached assets listed.

Don't see the cache or it's empty?

Try clicking Unregister next to your service worker in the Service Workers panel, then refresh the page. This forces a fresh install cycle.

If any file in your ASSETS_TO_CACHE array returns a 404, the entire cache.addAll() call fails. Double check that your icon files exist at /icons/icon-192x192.png and /icons/icon-512x512.png.

Help me figure out why my cache storage is empty.

✔️ Awesome, I've got everything!

Great. Double check you've saved sw.js and you can see all seven assets in Cache Storage.

ⓧ I'd like to double check the full code

Here's the complete sw.js file for comparison:

const CACHE_NAME = "task-tracker-v1";
const ASSETS_TO_CACHE = [
  "/",
  "/index.html",
  "/style.css",
  "/app.js",
  "/manifest.json",
  "/icons/icon-192x192.png",
  "/icons/icon-512x512.png"
];

// Install event - precache assets
self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      console.log("Caching app assets");
      return cache.addAll(ASSETS_TO_CACHE);
    })
  );
});

// Activate event - clean up old caches
self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME)
          .map((name) => caches.delete(name))
      );
    })
  );
});

// Fetch event - Cache-First strategy
self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((cachedResponse) => {
      if (cachedResponse) {
        return cachedResponse;
      }
      return fetch(event.request);
    })
  );
});

Your service worker is installed, activated, and caching assets. Next up, you'll put it to the ultimate test by going offline and installing the app on your desktop.

Test Offline Mode and Install the App

Your service worker is activated and caching every asset your app needs. The Cache-First strategy you wrote intercepts fetch requests and serves cached responses instantly.

Now it's time to prove that it actually works. In this step, you'll simulate going offline, confirm the app loads entirely from cache, and then install the PWA as a standalone desktop app.

In this step, get ready to:
  • Test offline mode using Chrome DevTools.
  • Install the PWA on your desktop.
  • Verify the full standalone experience works without internet.
Test offline functionality

The real test of a PWA is whether it loads without any network connection. Chrome DevTools lets you simulate going offline without actually disconnecting your Wi-Fi.

  • In Chrome, open DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
  • Click the Network tab at the top of the DevTools panel.
  • Check the Offline checkbox near the top of the Network panel.
  • Reload the page by pressing Cmd+R (macOS) or Ctrl+R (Windows).

The page should load completely. Your task tracker UI appears just as it did before, even though the network is disabled.

Why does this work?

When you reload while offline, the browser sends fetch requests that your service worker intercepts. The Cache-First strategy checks the cache before trying the network. Since all assets were precached during the install event, every request gets a cached response.

The network never gets hit, so going offline makes no difference.

  • Type Buy groceries into the task input and click Add.
  • Confirm the task appears in the list below the input field.
  • Reload the page again while still offline.
  • Confirm the task you just added is still there after the reload.

Why do tasks persist offline?

Tasks are stored in localStorage, which is a browser storage mechanism that works entirely on your device. It does not need a network connection to read or write data.

  • Uncheck the Offline checkbox in the Network tab to restore your connection.

Page not loading while offline?

Make sure your service worker shows as "activated and running" in the Application > Service Workers panel. If it says "waiting to activate", click skipWaiting and reload.

Also check Application > Cache Storage. You should see task-tracker-v1 with all seven assets listed. If the cache is empty, the install event did not complete successfully.

Help me debug why my service worker isn't serving cached assets offline.

Install the PWA

Your app meets all the installability criteria. Chrome recognizes it as a PWA and offers to install it as a standalone desktop app.

  • Look in Chrome's address bar on the right side for a small install icon (a monitor with a down arrow).
  • Click the install icon.

Can't see the install icon?

Click the three-dot menu in the top-right corner of Chrome. Look for Install Task Tracker PWA in the menu and click it.

  • In the install dialog that appears, click Install.

The app opens in its own standalone window. There is no address bar, no browser tabs, and no navigation buttons. It looks and feels like a native desktop app.

  • Confirm the app icon appears in your taskbar (Windows) or Dock (macOS).
  • Close the standalone window completely.
  • Relaunch the app from your taskbar or Dock to verify it opens again in the standalone window.

What makes this a standalone window?

The "display": "standalone" field in your manifest.json tells the browser to launch the PWA without any browser UI. The theme_color value sets the title bar color of the standalone window.

Install option not appearing?

Open Application > Manifest in DevTools and look for warnings. Common issues include a missing icon size (you need both 192x192 and 512x512) or a start_url that doesn't match a cached route.

Help me figure out why Chrome isn't showing the install option for my PWA.

Verify the full PWA experience

The final test is to confirm everything works together. A properly configured PWA should be completely independent of your internet connection.

  • With the app running in its standalone window, disconnect your Wi-Fi or Ethernet completely.
  • Add a new task (e.g. Walk the dog) and confirm it appears in the list.
  • Click on a task to mark it as completed. Confirm the strikethrough styling appears.
  • Reconnect your Wi-Fi.
  • Confirm the tasks you created while offline are still there.

Your app launches in a standalone window with the blue theme color, works fully offline, and persists tasks across sessions. That is a complete PWA.

What are the three pillars of a PWA?

You have now seen all three in action. The Web App Manifest makes the app installable. The Service Worker intercepts network requests and serves cached assets. The Cache API stores the assets so they are available without a network connection.

Together, they turn a regular website into something that behaves like a native app.

Secret mission

Build a Custom Install Button

What if you could control exactly when and how the install prompt appears? In this mission, you'll intercept Chrome's default install behavior and build your own custom install button that appears right in the app's header.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The task-tracker-pwa project folder on your Desktop.
  • The globally installed serve npm package.
  • The installed Task Tracker PWA (if you installed it during the project).

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with the PWA.

  • Your project files stay on your Desktop.
  • The global serve package uses negligible disk space and has no background processes.
  • The installed PWA remains accessible from your taskbar or start menu.

Pause - I'll come back to this later

Stop the running server to free up the terminal. Your files and installed packages stay for next time.

  • In the terminal where serve is running, press Ctrl+C to stop the local server.
  • Your project files and the globally installed serve package remain on your machine for when you return.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to fully clean up.

  • In the terminal where serve is running, press Ctrl+C to stop the local server.
  • If you installed the PWA, open it from your taskbar. Click the three-dot menu icon in the top-right corner of the app window, then select Uninstall Task Tracker PWA.
  • Remove the globally installed serve package by running this command:
npm uninstall -g serve
  • Delete the project folder. On macOS or Linux, run:
rm -rf ~/Desktop/task-tracker-pwa
  • On Windows, run:
rmdir /s /q %USERPROFILE%\Desktop\task-tracker-pwa

Nice Work!

Nice Work!

Nice work! You just built a fully offline-capable, installable task tracker from scratch using nothing but HTML, CSS, and vanilla JavaScript.

You've learned how to:

  • Built a complete task tracker and turned it into an installable desktop app using a web app manifest with custom icons, theme colors, and standalone display mode.
  • Wrote a service worker from scratch implementing the Cache-First strategy, precaching assets on install and serving them directly from the cache for full offline support.
  • Tested offline functionality using Chrome DevTools Network throttling and installed the PWA as a standalone desktop application with no browser chrome.
  • Secret Mission: Built a custom in-app install button using the beforeinstallprompt event to replace Chrome's default install banner with your own UI.

Ready to quiz yourself?