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# -------------------------------------------------------
56# Run the app
57# -------------------------------------------------------
58if __name__ == "__main__":
59    app.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.

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}

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

Hint

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. Un-ticking 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", { keyPath: "id", autoIncrement: true });
 77        }
 78      };
 79
 80      request.onsuccess = (e) => {
 81        resolve(e.target.result);
 82      };
 83      request.onerror = (e) => reject(e.target.error);
 84    });
 85  }
 86  return dbPromise;
 87}
 88
 89// -------------------------------
 90// 4. STORING & RETRIEVING TASKS OFFLINE
 91// -------------------------------
 92async function storeTasksOffline(tasks) {
 93  const db = await initDB();
 94  const tx = db.transaction("tasks", "readwrite");
 95  const store = tx.objectStore("tasks");
 96  // Clear existing tasks
 97  await store.clear();
 98  // Insert/update each task
 99  for (const task of tasks) {
100    await store.put(task);
101  }
102  await tx.done;
103}
104
105function getTasksOffline() {
106  return new Promise((resolve, reject) => {
107    initDB().then((db) => {
108      const tx = db.transaction("tasks", "readonly");
109      const store = tx.objectStore("tasks");
110      const req = store.getAll();
111      req.onsuccess = () => resolve(req.result);
112      req.onerror = () => reject(req.error);
113    }).catch(reject);
114  });
115}
116
117// -------------------------------
118// 5. STORING & REPLAYING OFFLINE REQUESTS
119// -------------------------------
120async function queueRequest(method, url, body) {
121  const db = await initDB();
122  const tx = db.transaction("requests", "readwrite");
123  const store = tx.objectStore("requests");
124  await store.add({ method, url, body, timestamp: Date.now() });
125  await tx.done;
126}
127
128// Retrieve all queued requests
129function getAllRequests() {
130  return new Promise((resolve, reject) => {
131    initDB().then((db) => {
132      const tx = db.transaction("requests", "readonly");
133      const store = tx.objectStore("requests");
134      const req = store.getAll();
135      req.onsuccess = () => resolve(req.result);
136      req.onerror = () => reject(req.error);
137    }).catch(reject);
138  });
139}
140
141// Remove a request from the queue once replayed
142async function removeRequest(id) {
143  const db = await initDB();
144  const tx = db.transaction("requests", "readwrite");
145  const store = tx.objectStore("requests");
146  await store.delete(id);
147  await tx.done;
148}
149
150// Replay queued offline ops
151async function replayRequests() {
152  const allRequests = await getAllRequests();
153  if (!allRequests.length) return;
154
155  for (const req of allRequests) {
156    try {
157      if (req.method === "POST") {
158        // Attempt to POST to the server
159        const res = await fetch(req.url, {
160          method: "POST",
161          headers: { "Content-Type": "application/json" },
162          body: JSON.stringify(req.body),
163        });
164        if (!res.ok) throw new Error("POST replay failed");
165      } else if (req.method === "DELETE") {
166        // Attempt to DELETE from the server
167        const res = await fetch(req.url, { method: "DELETE" });
168        if (!res.ok) throw new Error("DELETE replay failed");
169      }
170      // If successful, remove from queue
171      await removeRequest(req.id);
172    } catch (err) {
173      console.error("[SW] Replay failed:", err);
174      // If it fails again, we'll keep it in the queue
175    }
176  }
177
178  // Now that we've replayed ops, let's re-fetch tasks from the server to refresh local store
179  try {
180    const response = await fetch("/api/tasks");
181    if (response.ok) {
182      const tasks = await response.json();
183      await storeTasksOffline(tasks);
184    }
185  } catch (err) {
186    console.warn("[SW] Could not refresh tasks after replay:", err);
187  }
188}
189
190async function addTaskToLocalStore(task) {
191  const db = await initDB();
192  const tx = db.transaction("tasks", "readwrite");
193  const store = tx.objectStore("tasks");
194  await store.put(task); // Put/Update this single task
195  await tx.done;
196}
197
198async function removeTaskFromLocalStore(id) {
199  const db = await initDB();
200  const tx = db.transaction("tasks", "readwrite");
201  const store = tx.objectStore("tasks");
202  await store.delete(id);
203  await tx.done;
204}
205
206// -------------------------------
207// 6. FETCH EVENT INTERCEPTION
208// -------------------------------
209self.addEventListener("fetch", (event) => {
210  const { request } = event;
211
212  // Only handle same-origin requests
213  if (!request.url.startsWith(self.location.origin)) {
214    return; // Skip cross-origin
215  }
216
217  // If not /api/tasks, do a cache-first approach for static files
218  if (!request.url.includes("/api/tasks")) {
219    event.respondWith(
220      caches.match(request).then((cachedResponse) => {
221        return cachedResponse || fetch(request);
222      })
223    );
224    return;
225  }
226
227  // Handle /api/tasks logic
228  if (request.method === "GET") {
229    // Network-first for GET /api/tasks
230    event.respondWith(
231      (async () => {
232        try {
233          // 1. Try network
234          const networkResponse = await fetch(request);
235          // 2. If successful, store tasks offline
236          const cloned = networkResponse.clone();
237          const tasks = await cloned.json();
238          await storeTasksOffline(tasks);
239          // 3. Return real network response
240          return networkResponse;
241        } catch (err) {
242          console.warn("[SW] GET /api/tasks offline, returning local tasks:", err);
243          // 4. If offline, return tasks from IndexedDB
244          const offlineTasks = await getTasksOffline();
245          return new Response(JSON.stringify(offlineTasks), {
246            headers: { "Content-Type": "application/json" },
247          });
248        }
249      })()
250    );
251  } else if (request.method === "POST") {
252    event.respondWith(
253      (async () => {
254        const clonedRequest = request.clone();
255        try {
256          // Attempt network
257          const networkResponse = await fetch(request);
258          return networkResponse;
259        } catch (err) {
260          console.warn("[SW] Offline, queueing POST request:", err);
261
262          // Read the body from the cloned request
263          const taskData = await clonedRequest.json(); 
264          // e.g. { text: "Buy milk" }
265
266          // Generate a LARGE placeholder ID so it sorts at the bottom
267          // This ID is guaranteed bigger than typical server IDs.
268          const offlineId = 10_000_000_000_000 + Date.now(); 
269          // or something similarly large
270
271          // Create an offline placeholder task
272          const offlineTask = {
273            id: offlineId,
274            text: taskData.text,
275            offline: true // optional flag to mark it as offline
276          };
277
278          // Store this task in the "tasks" object store so it appears in offline data
279          await addTaskToLocalStore(offlineTask);
280
281          // Queue the original request for replay
282          await queueRequest("POST", request.url, taskData);
283
284          // Return a fake 201 so the front-end thinks creation succeeded
285          // Optionally return some minimal JSON that matches a server response
286          return new Response(
287            JSON.stringify({
288              message: "Offline creation success",
289              id: offlineId
290            }),
291            { status: 201, headers: { "Content-Type": "application/json" } }
292          );
293        }
294      })()
295    );
296  } else if (request.method === "DELETE") {
297    event.respondWith(
298      (async () => {
299        const requestClone = request.clone();
300        try {
301          const networkResponse = await fetch(request);
302          return networkResponse;
303        } catch (err) {
304          console.warn("[SW] Offline, queueing DELETE request:", err);
305
306          // 1. Parse the task ID from the URL or request
307          // e.g., /api/tasks/123
308          const urlParts = requestClone.url.split("/");
309          const taskIdStr = urlParts[urlParts.length - 1];
310          const taskId = Number(taskIdStr);
311
312          // 2. Remove from local 'tasks' store immediately
313          if (!isNaN(taskId)) {
314            await removeTaskFromLocalStore(taskId);
315          }
316
317          // 3. Queue the request so it replays later
318          await queueRequest("DELETE", requestClone.url, null);
319
320          // 4. Return a fake success
321          return new Response(
322            JSON.stringify({ message: "Deleted offline." }),
323            { status: 200, headers: { "Content-Type": "application/json" } }
324          );
325        }
326      })()
327    );
328  }
329});
330
331// -------------------------------
332// 7. SYNC WHEN ONLINE AGAIN
333// -------------------------------
334self.addEventListener("message", (event) => {
335  if (event.data && event.data.type === "ONLINE") {
336    console.log("[SW] Received message to replay requests");
337    event.waitUntil(replayRequests());
338  }
339});

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 descendents 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>