5.4. PWA Tutorial#

In this tutorial we will work step-by-step to build a To-Do list PWA using a Flask backend.

At the end of the tutorial you will have built a PWA that:

  • works like a website and can be used normally in a browser

  • can be installed as a local app

  • works online and offline, with to-do items synchronised on reconnection to the server

Note

While one of the key principles of a PWA is a responsive design, we will ignore the design and styling of the PWA in order to keep the code as simple as possible.

5.4.1. Setup#

To begin, let’s take a look at the overall structure of the project below

Directory structure#
my-todo-app/
│
├── app.py
├── static/
│   └── js/
│       └── app.js
└── templates/
    └── index.html

Explanation:

  • app.py contains Flask code responsible for the back end

  • static will contain static files such as the JavaScript in app.js responsible for the front end

  • templates contains the template file index.html

Backend#

The backend code in app.py performs the following:

  1. Create an sqlite database called tasks.db if it doesn’t exist

  2. Define a Task model using SQLAlchemy’s ORM

  3. Setup routes for:

    1. The homepage, which just renders the homepage template

    2. GET-ing the list of all tasks in the database

    3. POST-ing a new task to add to the database

app.py#
 1from flask import Flask, render_template, request, jsonify
 2from flask_sqlalchemy import SQLAlchemy
 3import os
 4
 5app = Flask(__name__)
 6
 7# -------------------------------------------------------
 8# Database configuration
 9# -------------------------------------------------------
10app.config["SQLALCHEMY_DATABASE_URI"]  = f"sqlite:///tasks.db"
11db = SQLAlchemy(app)
12
13# Define the Task Model
14class Task(db.Model):
15    id = db.Column(db.Integer, primary_key=True)
16    text = db.Column(db.String(255), nullable=False)
17
18    def to_dict(self):
19        return {
20            "id": self.id,
21            "text": self.text
22        }
23
24# Create the database if it doesn't exist
25with app.app_context():
26    db.create_all()
27
28# -------------------------------------------------------
29# Routes
30# -------------------------------------------------------
31@app.route("/")
32def home():
33    """Serve the index page (homepage)"""
34    return render_template("index.html")
35
36@app.route("/api/tasks", methods=["GET"])
37def get_tasks():
38    """Return all tasks as JSON."""
39    tasks = Task.query.all()
40    return jsonify([task.to_dict() for task in tasks]), 200
41
42
43@app.route("/api/tasks", methods=["POST"])
44def create_task():
45    """Create a new task."""
46    data = request.get_json()
47    if not data or "text" not in data:
48        return jsonify({"error": "Invalid data"}), 400
49
50    new_task = Task(text=data["text"])
51    db.session.add(new_task)
52    db.session.commit()
53    return jsonify(new_task.to_dict()), 201
54
55@app.route("/api/tasks/<int:task_id>", methods=["DELETE"])
56def delete_task(task_id):
57    """Delete an existing task."""
58    task = Task.query.get(task_id)
59    if not task:
60        return jsonify({"error": "Task not found"}), 404
61
62    db.session.delete(task)
63    db.session.commit()
64    return jsonify({"message": f"Task {task_id} deleted"}), 200
65
66# -------------------------------------------------------
67# Run the app
68# -------------------------------------------------------
69app.run(debug=True, reloader_type='stat', port=5000)

Frontend#

The frontend is composed of two files:

  1. The index.html template which launches the controlling app.js code and has user interface placeholders

  2. The app.js JavaScript that controls the webpage contents and connects to the backend

index.html

In the index.html template shown below:

  • A small form is presented to enter short to-do items

  • A placeholder <ul> is created which will be updated by the JavaScript code

  • The app.js script is loaded, which controls the web page

index.html#
 1<!DOCTYPE html>
 2<html lang="en">
 3<head>
 4  <meta charset="UTF-8" />
 5  <title>PWA To-Do List</title>
 6</head>
 7<body>
 8  <h1>My PWA To-Do List</h1>
 9  
10  <!-- Input row -->
11  <div class="input-group">
12    <input type="text" id="taskInput" placeholder="Enter a new task" />
13    <button id="addTaskBtn">Add Task</button>
14  </div>
15  
16  <!-- Task list, controlled by app.js -->
17  <ul id="todoList"></ul>
18
19  <!-- Main app logic -->
20  <script src="/static/js/app.js"></script>
21</body>
22</html>

app.js

The app.js file is responsible for:

  • retrieving the list of tasks from the server and populating the <ul> on the first page load

  • responding to events such as creating a task or deleting a task

app.js#
 1// DOM Elements
 2const taskInput = document.getElementById("taskInput");
 3const addTaskBtn = document.getElementById("addTaskBtn");
 4const todoList = document.getElementById("todoList");
 5
 6// Fetch tasks from the server and render them
 7async function fetchTasks() {
 8  try {
 9    const response = await fetch("/api/tasks");
10    if (!response.ok) {
11      throw new Error("Failed to fetch tasks");
12    }
13    const tasks = await response.json();
14    renderTasks(tasks);
15  } catch (error) {
16    console.error(error);
17  }
18}
19
20// Render tasks onto the page
21function renderTasks(tasks) {
22  todoList.innerHTML = "";
23  tasks.forEach((task) => {
24    const li = document.createElement("li");
25
26    // Task text
27    const span = document.createElement("span");
28    span.textContent = task.text;
29    li.appendChild(span);
30
31    // Delete button
32    const delBtn = document.createElement("button");
33    delBtn.className = "delete-btn";
34    delBtn.textContent = "X";
35    delBtn.addEventListener("click", () => {
36      deleteTask(task.id);
37    });
38
39    li.appendChild(delBtn);
40    todoList.appendChild(li);
41  });
42}
43
44// Create a new task (send to server)
45async function createTask() {
46  const newTaskText = taskInput.value.trim();
47  if (!newTaskText) return;
48
49  try {
50    const response = await fetch("/api/tasks", {
51      method: "POST",
52      headers: { "Content-Type": "application/json" },
53      body: JSON.stringify({ text: newTaskText }),
54    });
55
56    if (!response.ok) {
57      throw new Error("Failed to create task");
58    }
59
60    const createdTask = await response.json();
61    // Re-fetch tasks to update the list
62    await fetchTasks();
63    // Clear input
64    taskInput.value = "";
65  } catch (error) {
66    console.error(error);
67  }
68}
69
70// Delete a task by ID
71async function deleteTask(taskId) {
72  try {
73    const response = await fetch(`/api/tasks/${taskId}`, {
74      method: "DELETE",
75    });
76    if (!response.ok) {
77      throw new Error("Failed to delete task");
78    }
79    await fetchTasks(); // Re-fetch tasks to update the list
80  } catch (error) {
81    console.error(error);
82  }
83}
84
85// Event Listeners
86addTaskBtn.addEventListener("click", createTask);
87taskInput.addEventListener("keyup", (e) => {
88  if (e.key === "Enter") {
89    createTask();
90  }
91});
92
93// Initial fetch to populate the task list
94fetchTasks();

5.4.2. Installable PWA#

So far we’ve created fairly standard website. To make it installable as a PWA we need to add the following:

  1. The manifest (manifest.json), describing the PWA

  2. An icon for installed application

The new project structure is below

Directory structure#
my-todo-app/
│
├── app.py
├── static/
│   ├── images/
│   │   └── icon-512.png
│   ├── js/
│   │   └── app.js
│   └── manifest.json
└── templates/
    └── index.html

Manifest#

The manifest provides some basic information to the web browser and operating system about how the PWA should be installed and presented to the user.

You’ll notice:

  • The manifest.json file is located under /static

  • The manifest is referenced in the index.html template so that the browser knows that the app is a PWA.

manifest.json#
 1{
 2  "name": "PWA To-Do List",
 3  "short_name": "ToDo",
 4  "start_url": "/",
 5  "display": "standalone",
 6  "background_color": "#ffffff",
 7  "theme_color": "#0d6efd",
 8  "icons": [
 9    {
10      "src": "/static/images/icon-512.png",
11      "sizes": "512x512",
12      "type": "image/png"
13    }
14  ]
15}
Excerpt of index.html#
1<!DOCTYPE html>
2<html lang="en">
3<head>
4  <meta charset="UTF-8" />
5  <title>PWA To-Do List</title>
6  
7  <!-- Link to manifest -->
8  <link rel="manifest" href="/static/manifest.json"/>
9</head>

Icon#

You need to provide an icon to make the PWA installable. Below you can find the included icon at 512x512 size, feel free to change the icon to your liking.

../../_images/icon-512.png

The included app icon.#

5.4.3. Working Offline#

To make the app “progressive” by working when offline (disconnected from the web or the server), we need to:

  1. Write a service worker, which

    • intercepts network requests

    • manages offline behaviour

  2. Serve the service-worker.js file from the root of the URL, which allows it to intercept network requests

  3. Add code to app.js to notify the service worker when the computer connects to the internet so that offline changes can be synchronised.

  4. Update the index.html template to register the service worker

Note

Once you’re done working through the steps below, you can run the PWA and test the offline behaviour using the developer tools in your browser.

For example in Google Chrome under the “Application > Service workers” developer options you can tick “Offline” at the top of the page to put the window or tab into offline mode. Unticking the option restores the connection.

The new project structure is below

Directory structure#
my-todo-app/
│
├── app.py
├── static/
│   ├── images/
│   │   └── icon-512.png
│   ├── js/
│   │   ├── app.js
│   │   └── service-worker.js
│   └── manifest.json
└── templates/
    └── index.html

Service Worker#

The service worker definition is quite long as it handles the complex task of managing offline behaviour.

To summarise the service worker:

  1. Caches files to make them available offline

  2. Create an in-browser database to:

    • Manage a copy of the task list so that the tasks are available offline

    • Keep a copy of requests that happened offline so that they can be replayed to the server when reconnected

  3. Intercept network requests

    • Attempt to send the requests normally

    • If the request fails because the computer is offline, store the requests to be replayed later

  4. Listen for messages from app.js (main thread) that the computer has connected to the internet

service-worker.js#
  1// -------------------------------
  2// CONFIG
  3// -------------------------------
  4const CACHE_NAME = "todo-pwa-v2";
  5const STATIC_FILES = [
  6  "/", // root page
  7  "/static/js/app.js",
  8  "/static/manifest.json",
  9  // Add your CSS, icons, or other files here
 10];
 11
 12// The name/version of our IndexedDB
 13const DB_NAME = "todo_db";
 14const DB_VERSION = 2; // bump if you add new stores or changes
 15
 16// -------------------------------
 17// 1. INSTALL & CACHE STATIC ASSETS
 18// -------------------------------
 19self.addEventListener("install", (event) => {
 20  event.waitUntil(
 21    caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_FILES)),
 22  );
 23  // Force the waiting service worker to become the active service worker
 24  self.skipWaiting();
 25});
 26
 27// -------------------------------
 28// 2. ACTIVATE & CLEAN OLD CACHES
 29// -------------------------------
 30self.addEventListener("activate", (event) => {
 31  event.waitUntil(
 32    (async () => {
 33      // Delete older caches
 34      const keys = await caches.keys();
 35      await Promise.all(
 36        keys.map((key) => {
 37          if (key !== CACHE_NAME) {
 38            return caches.delete(key);
 39          }
 40        }),
 41      );
 42      // Take control of all pages
 43      self.clients.claim();
 44
 45      // Attempt to replay offline requests if any exist
 46      await replayRequests();
 47    })(),
 48  );
 49});
 50
 51// -------------------------------
 52// 3. INDEXEDDB SETUP
 53//    - "tasks" store holds actual tasks { id, text }
 54//    - "requests" store queues offline POST/DELETE ops
 55// -------------------------------
 56let dbPromise = null;
 57
 58/**
 59 * Open (or create) the IndexedDB with "tasks" and "requests" stores.
 60 */
 61function initDB() {
 62  if (!dbPromise) {
 63    dbPromise = new Promise((resolve, reject) => {
 64      const request = indexedDB.open(DB_NAME, DB_VERSION);
 65
 66      request.onupgradeneeded = (e) => {
 67        const db = e.target.result;
 68
 69        // 1. tasks store: keyPath = "id"
 70        if (!db.objectStoreNames.contains("tasks")) {
 71          db.createObjectStore("tasks", { keyPath: "id" });
 72        }
 73
 74        // 2. requests store: keyPath = "id" (autoIncrement for queued ops)
 75        if (!db.objectStoreNames.contains("requests")) {
 76          db.createObjectStore("requests", {
 77            keyPath: "id",
 78            autoIncrement: true,
 79          });
 80        }
 81      };
 82
 83      request.onsuccess = (e) => {
 84        resolve(e.target.result);
 85      };
 86      request.onerror = (e) => reject(e.target.error);
 87    });
 88  }
 89  return dbPromise;
 90}
 91
 92// -------------------------------
 93// 4. STORING & RETRIEVING TASKS OFFLINE
 94// -------------------------------
 95async function storeTasksOffline(tasks) {
 96  const db = await initDB();
 97  const tx = db.transaction("tasks", "readwrite");
 98  const store = tx.objectStore("tasks");
 99  // Clear existing tasks
100  await store.clear();
101  // Insert/update each task
102  for (const task of tasks) {
103    await store.put(task);
104  }
105  await tx.done;
106}
107
108function getTasksOffline() {
109  return new Promise((resolve, reject) => {
110    initDB()
111      .then((db) => {
112        const tx = db.transaction("tasks", "readonly");
113        const store = tx.objectStore("tasks");
114        const req = store.getAll();
115        req.onsuccess = () => resolve(req.result);
116        req.onerror = () => reject(req.error);
117      })
118      .catch(reject);
119  });
120}
121
122// -------------------------------
123// 5. STORING & REPLAYING OFFLINE REQUESTS
124// -------------------------------
125async function queueRequest(method, url, body) {
126  const db = await initDB();
127  const tx = db.transaction("requests", "readwrite");
128  const store = tx.objectStore("requests");
129  await store.add({ method, url, body, timestamp: Date.now() });
130  await tx.done;
131}
132
133// Retrieve all queued requests
134function getAllRequests() {
135  return new Promise((resolve, reject) => {
136    initDB()
137      .then((db) => {
138        const tx = db.transaction("requests", "readonly");
139        const store = tx.objectStore("requests");
140        const req = store.getAll();
141        req.onsuccess = () => resolve(req.result);
142        req.onerror = () => reject(req.error);
143      })
144      .catch(reject);
145  });
146}
147
148// Remove a request from the queue once replayed
149async function removeRequest(id) {
150  const db = await initDB();
151  const tx = db.transaction("requests", "readwrite");
152  const store = tx.objectStore("requests");
153  await store.delete(id);
154  await tx.done;
155}
156
157// Replay queued offline ops
158async function replayRequests() {
159  const allRequests = await getAllRequests();
160  if (!allRequests.length) return;
161
162  for (const req of allRequests) {
163    try {
164      if (req.method === "POST") {
165        // Attempt to POST to the server
166        const res = await fetch(req.url, {
167          method: "POST",
168          headers: { "Content-Type": "application/json" },
169          body: JSON.stringify(req.body),
170        });
171        if (!res.ok) throw new Error("POST replay failed");
172      } else if (req.method === "DELETE") {
173        // Attempt to DELETE from the server
174        const res = await fetch(req.url, { method: "DELETE" });
175        if (!res.ok) throw new Error("DELETE replay failed");
176      }
177      // If successful, remove from queue
178      await removeRequest(req.id);
179    } catch (err) {
180      console.error("[SW] Replay failed:", err);
181      // If it fails again, we'll keep it in the queue
182    }
183  }
184
185  // Now that we've replayed ops, let's re-fetch tasks from the server to refresh local store
186  try {
187    const response = await fetch("/api/tasks");
188    if (response.ok) {
189      const tasks = await response.json();
190      await storeTasksOffline(tasks);
191    }
192  } catch (err) {
193    console.warn("[SW] Could not refresh tasks after replay:", err);
194  }
195}
196
197async function addTaskToLocalStore(task) {
198  const db = await initDB();
199  const tx = db.transaction("tasks", "readwrite");
200  const store = tx.objectStore("tasks");
201  await store.put(task); // Put/Update this single task
202  await tx.done;
203}
204
205async function removeTaskFromLocalStore(id) {
206  const db = await initDB();
207  const tx = db.transaction("tasks", "readwrite");
208  const store = tx.objectStore("tasks");
209  await store.delete(id);
210  await tx.done;
211}
212
213// -------------------------------
214// 6. FETCH EVENT INTERCEPTION
215// -------------------------------
216self.addEventListener("fetch", (event) => {
217  const { request } = event;
218
219  // Only handle same-origin requests
220  if (!request.url.startsWith(self.location.origin)) {
221    return; // Skip cross-origin
222  }
223
224  // If not /api/tasks, do a cache-first approach for static files
225  if (!request.url.includes("/api/tasks")) {
226    event.respondWith(
227      caches.match(request).then((cachedResponse) => {
228        return cachedResponse || fetch(request);
229      }),
230    );
231    return;
232  }
233
234  // Handle /api/tasks logic
235  if (request.method === "GET") {
236    // Network-first for GET /api/tasks
237    event.respondWith(
238      (async () => {
239        try {
240          // 1. Try network
241          const networkResponse = await fetch(request);
242          // 2. If successful, store tasks offline
243          const cloned = networkResponse.clone();
244          const tasks = await cloned.json();
245          await storeTasksOffline(tasks);
246          // 3. Return real network response
247          return networkResponse;
248        } catch (err) {
249          console.warn(
250            "[SW] GET /api/tasks offline, returning local tasks:",
251            err,
252          );
253          // 4. If offline, return tasks from IndexedDB
254          const offlineTasks = await getTasksOffline();
255          return new Response(JSON.stringify(offlineTasks), {
256            headers: { "Content-Type": "application/json" },
257          });
258        }
259      })(),
260    );
261  } else if (request.method === "POST") {
262    event.respondWith(
263      (async () => {
264        const clonedRequest = request.clone();
265        try {
266          // Attempt network
267          const networkResponse = await fetch(request);
268          return networkResponse;
269        } catch (err) {
270          console.warn("[SW] Offline, queueing POST request:", err);
271
272          // Read the body from the cloned request
273          const taskData = await clonedRequest.json();
274          // e.g. { text: "Buy milk" }
275
276          // Generate a LARGE placeholder ID so it sorts at the bottom
277          // This ID is guaranteed bigger than typical server IDs.
278          const offlineId = 10_000_000_000_000 + Date.now();
279          // or something similarly large
280
281          // Create an offline placeholder task
282          const offlineTask = {
283            id: offlineId,
284            text: taskData.text,
285            offline: true, // optional flag to mark it as offline
286          };
287
288          // Store this task in the "tasks" object store so it appears in offline data
289          await addTaskToLocalStore(offlineTask);
290
291          // Queue the original request for replay
292          await queueRequest("POST", request.url, taskData);
293
294          // Return a fake 201 so the front-end thinks creation succeeded
295          // Optionally return some minimal JSON that matches a server response
296          return new Response(
297            JSON.stringify({
298              message: "Offline creation success",
299              id: offlineId,
300            }),
301            { status: 201, headers: { "Content-Type": "application/json" } },
302          );
303        }
304      })(),
305    );
306  } else if (request.method === "DELETE") {
307    event.respondWith(
308      (async () => {
309        const requestClone = request.clone();
310        try {
311          const networkResponse = await fetch(request);
312          return networkResponse;
313        } catch (err) {
314          console.warn("[SW] Offline, queueing DELETE request:", err);
315
316          // 1. Parse the task ID from the URL or request
317          // e.g., /api/tasks/123
318          const urlParts = requestClone.url.split("/");
319          const taskIdStr = urlParts[urlParts.length - 1];
320          const taskId = Number(taskIdStr);
321
322          // 2. Remove from local 'tasks' store immediately
323          if (!isNaN(taskId)) {
324            await removeTaskFromLocalStore(taskId);
325          }
326
327          // 3. Queue the request so it replays later
328          await queueRequest("DELETE", requestClone.url, null);
329
330          // 4. Return a fake success
331          return new Response(JSON.stringify({ message: "Deleted offline." }), {
332            status: 200,
333            headers: { "Content-Type": "application/json" },
334          });
335        }
336      })(),
337    );
338  }
339});
340
341// -------------------------------
342// 7. SYNC WHEN ONLINE AGAIN
343// -------------------------------
344self.addEventListener("message", (event) => {
345  if (event.data && event.data.type === "ONLINE") {
346    console.log("[SW] Received message to replay requests");
347    event.waitUntil(replayRequests());
348  }
349});

Serving the Service Worker#

Part of the security restrictions of service workers is that they can only listen for network requests made to the same path, or descendants of the path, that they are served from. This means we need to serve the service-worker.js file from /service-worker.js to intercept all network requests made to the server.

By using Flask’s send_from_directory we can add a new view to serve the file at the root path.

app.py#
37@app.route("/service-worker.js")
38def service_worker():
39    # Make sure the correct MIME type is used for the service worker.
40    return send_from_directory("static/js", "service-worker.js")

Online Event#

When the browser detects online/offline conditions it broadcasts these to any listening objects. We can pass these events on to the service worker through the inbuilt messaging system that allows the main browser thread and service workers to communicate.

Adding the code below to app.js achieves this task:

app.js#
93window.addEventListener("online", () => {
94  if (navigator.serviceWorker && navigator.serviceWorker.controller) {
95    navigator.serviceWorker.controller.postMessage({ type: "ONLINE" });
96  }
97});

Register Service Worker#

To finally register the service worker you can add the small snippet of JavaScript shown below to index.html.

index.html#
24  <!-- Register Service Worker -->
25  <script>
26  if ('serviceWorker' in navigator) {
27    navigator.serviceWorker.register('/service-worker.js').then((reg) => {
28      console.log('Service Worker registered:', reg);
29    });
30  }
31  </script>