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
my-todo-app
│
├── app.py
├── static
│ └── js
│ └── app.js
└── templates
└── index.html
Explanation:
app.py
contains Flask code responsible for the back endstatic
will contain static files such as the JavaScript inapp.js
responsible for the front endtemplates
contains the template fileindex.html
Backend#
The backend code in app.py
performs the following:
Create an sqlite database called
tasks.db
if it doesn’t existDefine a Task model using SQLAlchemy’s ORM
Setup routes for:
The homepage, which just renders the homepage template
GET
-ing the list of all tasks in the databasePOST
-ing a new task to add to the database
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:
The
index.html
template which launches the controllingapp.js
code and has user interface placeholdersThe
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 codeThe
app.js
script is loaded, which controls the web page
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 loadresponding to events such as creating a task or deleting a task
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:
The manifest (
manifest.json
), describing the PWAAn icon for installed application
The new project structure is below
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.
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.
5.4.3. Working Offline#
To make the app “progressive” by working when offline (disconnected from the web or the server), we need to:
Write a service worker, which
intercepts network requests
manages offline behaviour
Serve the
service-worker.js
file from the root of the URL, which allows it to intercept network requestsAdd code to
app.js
to notify the service worker when the computer connects to the internet so that offline changes can be synchronised.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
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:
Caches files to make them available offline
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
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
Listen for messages from
app.js
(main thread) that the computer has connected to the internet
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.
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:
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
.
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>