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.pycontains Flask code responsible for the back endstaticwill contain static files such as the JavaScript inapp.jsresponsible for the front endtemplatescontains the template fileindex.html
Backend#
The backend code in app.py performs the following:
Create an sqlite database called
tasks.dbif 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@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:
The
index.htmltemplate which launches the controllingapp.jscode and has user interface placeholdersThe
app.jsJavaScript 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.jsscript 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.
You’ll notice:
The
manifest.jsonfile is located under/staticThe manifest is referenced in the
index.htmltemplate so that the browser knows that the app is a PWA.
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}
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.
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:
Write a service worker, which
intercepts network requests
manages offline behaviour
Serve the
service-worker.jsfile from the root of the URL, which allows it to intercept network requestsAdd code to
app.jsto notify the service worker when the computer connects to the internet so that offline changes can be synchronised.Update the
index.htmltemplate 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
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", {
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.
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>