HTML Refresher and Detailed Study - Part 9

Last updated on 19 May 2026
Post series: Part 1,     Prev Part (8),     Post Series TOC

Contents (sections and/or jump-links)

Implementation of uppercase Echo Chat application with offline support using WebSockets and Service Worker

Stage 1: Simple delayed uppercase Echo Chat server and client with WebSockets but without Service Worker

Stage 2: Adding Service Worker Network First Caching (Offline App Shell)

Stage 3A: Offline Message Queuing with IndexedDB implemented with custom IndexedDB wrapper functions

Stage 3B: Offline Message Queuing with IndexedDB implemented with idb package

Plan for WebSocket auto-reconnect and advanced Progressive Web App features in uppercase Echo Chat app

Stage 4: WebSocket auto-reconnect

Disabling Live Server browser auto refresh for a project


Implementation of uppercase Echo Chat application with offline support using WebSockets and Service Worker

The overall objective of this app is to develop an uppercase echo chat server app with WebSockets and Service Worker functionality, as a learning exercise about how WebSockets and Service Workers can be used to improve functionality of such chat apps.

This implementation follows up on two of my recent blog posts:

  1. HTML Refresher and Detailed Study - Part 7 which covers (links below are to first sub-sections in the sections):
    1. Top-Level design of chat application with offline support using WebSockets and Service Worker
    2. Very Basic Service Worker
    3. Service Worker with Offline Caching
    4. Service Worker Misc: Next.js, React, PWA hype
  2. HTML Refresher and Detailed Study - Part 8 which covers (links below are to first sub-sections in the sections):
    1. Web Push API
    2. Vanilla JS Chat with WebSockets

Gemini chat: 11-HTML Refresher n Study

Dates: 15 May 2026 to 18 May 2026

Stage 1: Simple delayed uppercase Echo Chat server and client with WebSockets but without Service Worker

Me: I am starting a new chat but I want to give you the context of the chat through my blog post: https://raviswdev.blogspot.com/2026/05/html-refresher-and-detailed-study-part-8.html . Can you read the post and get the context?

(Gem was able to read the post and get the context.)

Me: Now it is time for implement uppercase echo chat server app with service worker.

We can do it step by step. First step is to create the simple uppercase echo chat server app and client without service worker.

For that, I think we should use Dave Gray's server implementation but modify it to:

  1. Delay response by some fixed amount (controlled by a constant).
  2. Send back uppercase version of received message.
  3. I think we should add a timestamp to the message too.

For the client, I think Thomas Sentre client is better as the chat messages are shown above the Send form (in Dave Gray client, it appears below the Send form).

But following changes should be done to Thomas Sentre client: 1) The JS embedded in index.html to should be moved to a script.js file 2) The JS code (should be simplified) by handling only text messages from server (Dave Gray server code sends back text messages). Client JS code can simply give an error message if Blob message is sent to it.

What do you think about my above plan? Feel free to provide your own suggestions.

Gem: Your plan is excellent. Taking an incremental approach by first establishing a solid native WebSocket connection before introducing the Service Worker is the most robust way to build this. It isolates your network logic from your caching and background sync logic, making debugging much easier.

Blending the two approaches makes perfect sense: Dave Gray’s backend provides a clean WebSocket server foundation, and Thomas Sentre’s UI layout (messages above the input) is the standard, expected user experience for modern chat applications.

One small detail to consider: since the Node.js ws package (version 8+) now yields a Buffer by default rather than a String, we need to ensure we parse it correctly on the server before applying the .toUpperCase() method. Also, since you want to stick strictly to text messages, we will prepend the timestamp directly to the string rather than wrapping it in a JSON object.

Gemini provided code for stage 1

Here is the implementation for Step 1 based exactly on your specifications.

(Gem provided the code for server and client. Snipped the code) ...

This gives you a functional, delayed, uppercase echo server with a clean UI, strictly validating plain text on the client and cleanly handling buffers on the server.

...

(Given below is a related section (slightly edited) from Readme in project folder (private GitHub repo):)

I created client and server folders and created below mentioned source files with code provided by Gemini.

  • client has index.html and script.js.
  • server has server.js.

One minor change I did was to change the server port from 8080 to 3000.

Setup and Run

In server folder:

  1. npm init -y
  2. npm i ws

To run server: node .\server.js

Open client/index.html using Live Server VS Code extension.

The app is running as expected.

Stage 1 folder created

I have copied the source files in stage-1 folder. This way, one can easily go back to app at this stage, when needed. The main folder source code will be updated as we progress in this task.

Stage 2: Adding Service Worker Network First Caching (Offline App Shell)

Overview

Note: This overview was provided by Gemini later on, towards end of Stage 2 implementation.

In this stage, a Service Worker (sw.js) was introduced to cache the static user interface assets (index.html, script.js, and CSS).

Key Implementations:

  • Network-First Strategy: The Service Worker intercepts HTTP requests and tries the network first. This ensures the app always serves the freshest code during active development.
  • Offline Fallback: If the network fails (or the server is down), it seamlessly falls back to the cached App Shell. The browser's default "No Internet" dinosaur is prevented, and the UI loads perfectly.
  • Cache Management: Implemented a targeted cleanup routine during the activate event that only deletes old caches prefixed with echo-chat-, preventing conflicts with other local Live Server projects.
  • Connection Status: Added logic to detect navigator.onLine alongside WebSocket state to accurately reflect simulated network drops in the UI.

Note: At this stage, the UI loads offline, but outgoing chat messages are lost if sent while disconnected. Offline message queuing is planned for the next stage.

Implementation Notes

(Gemini provided code changes for adding Service Worker Network First Caching to app. After some iterations, the code stabilized.)

Me: 1) Re: following code:

self.addEventListener('fetch', (event) => {
    // We only want to cache GET requests for static files
    if (event.request.method !== 'GET') return;

What happens for non GET requests? Does return simply cancel the request? Shouldn't we pass it on to regular fetch?

(I also asked for network first instead of cache first strategy to prevent zombie app possibility.)

Gem: ... Returning early does not cancel the request.

In the Service Worker fetch lifecycle, the browser only waits for you to intercept the request if you call event.respondWith(). If the event listener finishes executing (or you return early) without event.respondWith() ever being called, the Service Worker simply steps aside. The browser then takes over and handles the request exactly as it would if no Service Worker existed—by sending it straight to the network.

So, for POST requests, WebSocket upgrades, or any other non-GET methods, the early return is the cleanest way to say, "I am not handling this; let the browser do its default job." You do not need to manually write fetch(event.request) for them.

...

  1. fetch logic: It now tries the network first. If successful, it clones the response, updates the cache, and serves it. If the network throws an error (e.g., offline), the .catch() block catches it and serves the cached version.
  2. self.skipWaiting() and clients.claim(): I added these standard lifecycle management methods. They ensure that when you modify the sw.js file, the new Service Worker installs and takes over immediately, rather than waiting for you to close all browser tabs before updating.

...

Why have Pre-caching when we already have dynamic caching?

Me: As we have dynamic caching, why do we need to have pre-caching?

Gem: That is an excellent architectural question. If your fetch listener is already intercepting every successful network request and tossing it into the cache dynamically, pre-caching during the install phase might seem redundant.

However, you need both because they solve two entirely different problems. Pre-caching provides predictability, while dynamic caching provides adaptability.

Here is why you still need pre-caching for your App Shell:

1. The "Cold Start" Offline Problem

Dynamic caching only saves a file after your browser requests it.

Imagine you expand your app later to have an /about.html page or a /settings.js file. If a user visits your main chat page, your dynamic cache will save index.html and script.js. But if they lose their internet connection and then click the "Settings" button, the app will break. The dynamic cache never saw settings.js because the user had not navigated there yet.

Pre-caching solves this. By defining ASSETS_TO_CACHE in your install event, you force the browser to download the entire App Shell in the background the moment the user first visits. You guarantee that all critical routes and scripts are available offline immediately, regardless of what the user has or has not clicked on.

2. Atomic Installation (All or Nothing)

The Service Worker install event acts as a strict gatekeeper. Look at this line from your code: return cache.addAll(ASSETS_TO_CACHE);

cache.addAll() is an atomic operation. If even one file in that array fails to download (e.g., a typo in a file path, or the server blips), the entire Promise rejects, and the Service Worker aborts the installation.

This is a safety feature. It prevents your app from installing in a broken, half-cached state. Dynamic caching does not offer this safety net; it just caches whatever happens to succeed, which could leave your app missing crucial styling or logic offline.

3. Separation of Responsibilities

In a production app, you divide your caching strategy based on the type of asset:

  • Pre-caching (The App Shell): Used strictly for the core, unchanging files required to render the UI (index.html, core CSS, core JS, logos). You control this list.
  • Dynamic Caching (The Content): Used for unpredictable, user-generated, or heavy assets. Think of user avatars, API JSON responses, or blog post images. You do not want to force a user to download 50MB of chat history images during installation; you let dynamic caching handle those naturally as the user scrolls.

By using both, you ensure that your chat app has a guaranteed, unbreakable UI foundation (pre-caching), while still intelligently adapting to whatever network requests happen later (dynamic caching).

...

Me: Please give me code that uses async await and not .then.catch for both sw.js and script.js

(Gem refactored the code. I later fixed pre-cache file paths to be relative to sw.js as Live Server runs at project root which is at higher level.)

Stage 2 Testing

Test plan

To verify that the App Shell is working correctly:

  1. Refresh the page: Open your browser's Developer Tools (F12) and go to the Application tab. Look under Service Workers to see if sw.js is activated.
  2. Check the Cache: Under Storage > Cache Storage, you should see echo-chat-v1 containing your HTML and JS files.
  3. Go Offline:
  4. In the Network tab of DevTools, change the throttling dropdown from "No throttling" to Offline.
  5. Refresh the page.
  6. Result: The UI (input box, send button, and container) should still load perfectly. You will see a connection error in the console for the WebSocket (since the server is unreachable), but the "App Shell" is now successfully cached.

Testing Results

  1. DevTools Service Workers pane shows sw.js (of this folder) as activated. Console shows expected messsages (registered, Install Event, Caching app shell, Activate event). It also shows one unexpected message: [Service Worker] Removing old cache v2. More on that later.
  2. DevTools Cache does show echo-chat-v1 with expected contents.
  3. After DevTools Network tab -> throttling is set to offline followed by Refresh, it works as expected. Specifically:
    1. The page shows 'Disconnected from the Server' message and input box and Send button are shown. Not connected dinosaur is not shown.
    2. Browser console shows '[Service Worker] Network failed, serving from cache: ' followed by WebSocket connection failure error messages. So the result is generally as expected.

Now about the unexpected message, [Service Worker] Removing old cache v2: This is due to other service worker code in other folders of this project. v2 cache seems to have been created by one such service worker in another folder.

What's the best way to resolve such issues?

  1. Are service worker projects expected to be kept as separate standalone projects?
  2. Or when we go through cache names for deletion, can we look for a particular folder's service worker cache unique prefix?

Gem: You have hit on one of the most common and confusing quirks of local Service Worker development. Your deduction is 100% correct: that v2 cache belongs to a different project in your workspace.

Here is exactly why this happens and how to fix it.

How to prevent Service Worker deleting another Service Worker's cache for projects having subfolders with separate Service Workers

Why the Service Worker Deleted Another Folder's Cache

While a Service Worker's scope (what network requests it can intercept) is restricted to its own folder (e.g., /client/), the Cache Storage API is shared across the entire origin (e.g., [http://127.0.0.1:5500](http://127.0.0.1:5500)).

Because you are using Live Server at the root of your workspace, all your sub-folders share the same origin. When your new sw.js ran caches.keys(), it looked at the shared origin storage, saw a cache named v2, noticed it did not match echo-chat-v1, and ruthlessly deleted it.

(Ravi: Later I came to know that Live Server has a setting - liveServer.settings.root - which can be used to get Live Server to treat a subfolder as root. For more, see Live Server Settings doc.)

Are Service Worker Projects Supposed to be Standalone?

In production: Yes. Usually, a Progressive Web App (PWA) sits at the root of its own domain (e.g., chat.yourdomain.com). In that scenario, it is safe to delete any cache that doesn't match the current version, because the app owns the entire origin.

In development: No, it is completely normal to have multiple projects running on the same localhost origin. You just need to implement a slightly smarter cleanup strategy to prevent them from destroying each other's data.

The Solution: The Prefix Strategy

Your idea to look for a specific folder's unique prefix is exactly the industry-standard way to solve this. We will define a standard prefix for this specific app and only delete old caches that start with that prefix.

Here is the updated activate event for your sw.js:

...

Me: ... (The client code) does not check for ws.send failure. So if the network is disconnected, the UI does not show that Send has failed.

(Gem provided a solution which added IndexedDB. I did not want to use IndexedDB at this stage and so removed that part of the code but retained the error message part when user attempts to send message but network has been disconeected ... )

Gem: Yes, that is perfectly OK! Taking a step back and deferring the IndexedDB implementation to keep your current scope manageable is a smart, iterative development strategy.

Your code perfectly solves the main technical issue: it prevents the native WebSocket from throwing a fatal DOMException when trying to send over a closed connection, and it provides immediate visual feedback to the user.

...

Adding network online/offline detection code (navigator.onLine)

Me: (After initial connection establishment, when DevTools Network is made offline):

  • ws.readyState remains WebSocket.OPEN

(Seems like) DevTools setting is not (impacting) ws.readyState.

Gem: ... Your observation is completely accurate. When you toggle the Network tab to "Offline" in Chrome DevTools, it immediately blocks new HTTP requests (like fetch), but it does not instantly sever existing WebSocket TCP connections.

This actually mimics a real-world network problem called a "silent drop." If a user unplugs their ethernet cable or walks out of Wi-Fi range, their operating system doesn't always send a "connection closed" signal to the browser. The browser—and your ws.readyState—happily assumes the connection is still OPEN until it eventually times out (which can take several minutes).

The Solution: navigator.onLine

To fix this for your DevTools testing (and for basic real-world Wi-Fi drops), we need to check the browser's overall network state in addition to the WebSocket state.

The browser provides a boolean property called navigator.onLine. When you click "Offline" in DevTools, this property instantly changes to false.

Here is how you update your submit handler to use both checks:

...

The "Pro" Solution for Later: Heartbeats

While navigator.onLine fixes the DevTools issue perfectly, it's worth knowing that in production chat apps, developers don't trust navigator.onLine or ws.readyState entirely.

Instead, they implement a "Heartbeat" (Ping/Pong). Every 30 seconds, the client sends a tiny hidden message like {"type": "ping"}. If the server doesn't reply with {"type": "pong"} within 5 seconds, the client manually closes the connection and updates the UI to show it is disconnected.

For your current stage, the navigator.onLine check is exactly what you need to get your DevTools test working properly. Give it a try and let me know if it correctly catches your simulated offline state!

Me: That worked.

Stage 2: Further Testing

16:08, 16 May 2026

Me:

  1. Started htmlcssjs\chat-app\uppercase-echo-chat\server.
  2. Started stage-1 client: http://127.0.0.1:5501/htmlcssjs/chat-app/uppercase-echo-chat/stage-1/client/index.html
  3. DevTools -> Network: Changed 'No throttling' to Offline.
  4. Refereshed stage-1 client page. It showed Dinosaur page with ERR_INTERNET_DISCONNECTED
  5. Then started stage-2 (still main dev) client: http://127.0.0.1:5501/htmlcssjs/chat-app/uppercase-echo-chat/client/index.html
    1. It said: 'Connected to the Echo server' and showed input box and Send button
      • That was weird.
    2. On refresh, it said: 'Disconnected from the server' (in red) and showed input box and Send button
      • That was the expected behaviour.
    3. Why did first open not show 'Disconnected from the server'?

...

Me: I did open in a new tab. But after it showed Connected, I checked the DevTools Network pane. It showed Offline. so at least in my Chrome installation, the new tab seems to carry over the Offline DevTools thing.

I should also say that I opened Dev Tools in the tab after I did the first url retrieval action. Perhaps as DevTools was not open then, the Offline part simply did not come into play.

Gem: ... This is exactly how Chrome DevTools operates ... Chrome DevTools does not act as a system-wide network proxy. It is essentially a debugger that attaches itself to the specific renderer process of the tab you are looking at.

If DevTools is closed, the throttle literally does not exist. Here is the exact timeline of your test:

  1. You opened the new tab: The browser was fully online.
  2. The JavaScript executed: Your app instantly fired new WebSocket(). Because the network was completely open, it connected to your local server successfully. Your UI printed "Connected."
  3. You opened DevTools: The DevTools UI loaded and restored your previous settings (showing "Offline" in the dropdown).
  4. The Trap: DevTools started enforcing the offline throttle from that exact millisecond onward. It blocked any new HTTP requests. But, as we established earlier, it does not actively hunt down and sever existing WebSocket TCP connections. So your socket remained connected, even though the DevTools UI proudly declared the tab was "Offline."

...

Me: Now I opened a new tab. Then opened DevTools before specifying url in address bar. Checked that Offline was set (carried over).

Next I specified: http://127.0.0.1:5501/htmlcssjs/chat-app/uppercase-echo-chat/client/index.html in address bar (and pressed Enter IIRC).

This time it showed 'Disconnected from the server' (in red) and showed input box and Send button.

This confirms your previous explanation.

...

Me: If the network is made online again (through DevTools say), without a refresh of client, if we attempt to send a message we get 'Disconnected from the network or server. Message not sent.' message. Ideally, if we are online, we should attempt a websocket connection and if that goes through then send the message, else show the error message. What's ur view? Note that I am not talking about re-connection attempts every x seconds or whatever which seems to be the norm for chat apps. We can keep our app simple. what's ur view? Don't give me code now. Just share your view.

Gem: I think your proposed approach is excellent, and it makes perfect architectural sense for the constraints you have set for this project.

In systems design, what you are describing is essentially an "on-demand" or "lazy" reconnection pattern. Rather than forcing the client to continuously burn resources polling the server in the background (the standard auto-reconnect approach), the app simply waits until the user explicitly requests an action—in this case, clicking "Send."

Here is my view on why this is a highly elegant solution for your current stage, along with one technical reality to keep in mind:

The Benefits:

  • Ultimate Simplicity: It perfectly aligns with your overarching goal of keeping this app lean and free of heavy frameworks. You avoid having to write and manage background setInterval loops, connection back-off algorithms, and ping/pong heartbeats.
  • Resource Efficiency: If a user opens your app, loses their connection, and goes to lunch, there is no reason for their browser to frantically try to reconnect every five seconds while they are away. Reconnecting only when they return and try to interact with the app is much more resource-conscious.

The Technical Reality (The "Gotcha"): The only architectural hurdle to keep in mind is the asynchronous nature of the WebSocket handshake.

If the user clicks "Send" and the socket is closed, you cannot simply execute a command to reconnect and then immediately send the message on the next line of code. The TCP connection takes time to establish. The app will need to initialize the new connection, temporarily hold onto the user's text, and wait for the socket's open event to fire before it can finally push the payload through. If the server happens to be down, it will also need to handle that timeout gracefully so the user doesn't click "Send" ten times in a row out of frustration.

Overall, I think it is a remarkably clean and pragmatic approach that avoids the bloat of "enterprise" chat patterns while still providing a robust user experience.

Does this on-demand architecture sound like the path you would like to take when we are ready to write the logic?

Me: (I shared the related code) ... Let me see how you will change it to implement what you have said. if i find it is not too complicated I will consider using it.

Gem: To implement this "lazy reconnection" pattern, we have to make one fundamental architectural change: a WebSocket object cannot be reused once it closes. To reconnect, we must instantiate a brand new WebSocket object.

Because establishing a new connection takes a few milliseconds (the TCP handshake), we cannot just create it and immediately call ws.send(). We have to wait for the open event.

The cleanest way to handle this without messy nested callbacks is to wrap the connection process in a Promise.

Here is what the code looks like. I have broken it down so you can judge the complexity.

(Ravi: I snipped the code. Later on in Stage 4, we implemented WebSocket auto reconnect with exponential backoff. Please see that section in this document for details.)

Me: The code is good. But for this tutorial type needs, I think it adds too much complexity. We can simply document that we are keeping stage-2 simple and so, without page refresh, user clicking Send even after network is online and server is running, will still result in error. Page refresh solves the issue (but currently also clears history of messages). What's ur view? If you agree, please provide me related document section(s).

...

Design Decision: Simplified Connection Handling (provided by Gemini and lightly edited by me)

To keep this stage of the tutorial focused on Service Worker fundamentals, the application relies on the native behavior of the WebSocket API without implementing complex auto-reconnection algorithms.

Known Limitation: Reconnection Requires Refresh A native WebSocket object cannot be reused once its connection is closed. If the network connection is dropped and subsequently restored (for example, by toggling off the "Offline" mode in Chrome DevTools), the broken socket does not automatically heal.

Consequently, if a user attempts to send a message after the network is back online but before the socket is re-established, the application will still display the "Disconnected" error message.

The Workaround: A manual page refresh is currently required to instantiate a new WebSocket connection to the server.

Note: Refreshing the page will currently clear the local chat history. Handling offline message queuing and state persistence to solve this issue is planned for future stages involving IndexedDB.

---end Gemini message extract---

Stage 2 folder created

I have copied the client source files in stage-2 folder. The server code is same as in stage-1 folder).


Stage 3A: Offline Message Queuing with IndexedDB implemented with custom IndexedDB wrapper functions

(I uploaded current client files to Gemini and we proceeded to stage-3: adding IndexedDB database for offline message queue)

Gem: I have reviewed your uploaded files, and we are perfectly in sync. Your Stage 2 code is clean, well-structured, and ready to be built upon.

Welcome to Stage 3: Offline Message Queuing with IndexedDB.

Since Service Workers cannot intercept WebSocket traffic to cache outgoing messages, we are going to build a local "Outbox" using IndexedDB. Here is the plan:

  1. If offline: Instead of throwing an error and dropping the message, we will save it to the local database and render it in the UI with a "Pending" style.
  2. If online (on page load): When the WebSocket successfully connects, we will check the database. If there are pending messages, we will send them to the server and clear the database.

Here are the step-by-step updates you need to make to your Stage 2 files.

...

1. Update index.html (Add Pending Style)

We need a visual indicator for messages that are stuck in the outbox. Add this single line of CSS inside your <style> block, right below the .error class:

---snipped---

2. Update script.js (Add IndexedDB Helpers)

Add this block of code anywhere in your script.js (putting it right above the // Initialize WebSocket connection line is a good spot). This wraps the clunky, event-based IndexedDB API into clean, modern async/await functions.

---snipped---

3. Update script.js (Refactor Event Listeners)

Replace your existing ws.addEventListener('open', ...) and sendForm.addEventListener('submit', ...) with these updated versions.

The open listener now acts as our "sync" trigger, and the submit listener now falls back to saveToOutbox instead of just throwing an error.

---snipped---

(Note: Because your appendMessage function interpolates class="${senderType}", passing 'user pending' will neatly apply both the green user bubble styling and the yellow dashed pending styling).

Stage 3: Test plan

  1. Baseline: Load the page online. Send a message to ensure it still works normally.
  2. Go Offline: Change DevTools Network to "Offline" AND stop your Node.js server.
  3. Queue Data: Type and send 2 or 3 messages. You should see them appear with the yellow "Pending..." styling. (You can visually confirm they are saved by going to DevTools -> Application -> IndexedDB -> echo-chat-db -> outbox).
  4. Go Online: Turn DevTools Network back to "No throttling" AND restart your Node.js server.
  5. Sync: Because we are keeping the connection logic simple, you must Refresh the page. Upon refreshing, the open event will fire, read the database, and shoot all your pending messages to the server!

Give that a run and let me know if the synchronization sequence fires correctly upon refreshing!

IndexedDB tutorials

Me: I am new to IndexedDB. So I looked up a few text and video tutorials. One of the best text tutorials is: https://javascript.info/indexeddb

However, the code you have used, seems to define wrappers - initDB(), saveToOutbox() etc. I can see how these wrappers make the calling code from top-level event handlers in script.js easy to read (uses async await). Did I get this correctly?

(Other tutorials I had a look at:

  1. Decent video tutorial: How to use IndexedDB to store data for your web application in the browser ~36 mins, Nov. 2021 by Alex Eagleson.
  2. MDN text tutorial: Using IndexedDB seems to be rather complex but seems to be comprehensive.)

Custom wrapper functions provided by Gemini for IndexedDB code

Gem: You got this exactly right. You have perfectly identified the architectural purpose of those wrappers.

The javascript.info tutorial is fantastic because it teaches you the raw, native metal of the API. However, as you likely noticed in that tutorial, the native IndexedDB API is entirely event-driven. It relies heavily on onsuccess, onerror, and onupgradeneeded callbacks.

Here is why those wrappers are necessary for a clean, modern codebase:

The "Age" of IndexedDB

IndexedDB was designed and standardized around 2011. At that time, JavaScript did not have Promises or async/await (those were introduced later, in ES6/2015 and ES8/2017). The only way to handle asynchronous operations back then was the DOM event model.

If you tried to write your script.js submit handler using the raw, native IndexedDB API, your code would quickly devolve into a deeply nested pyramid of callbacks (often called "callback hell").

The Bridge: Promises

The wrapper functions (initDB, saveToOutbox, etc.) act as a bridge across time. They take the old 2011 event-based API and manually wrap it inside a modern JavaScript Promise.

For example, inside initDB():

  • We tell the Promise to resolve() only when the native request.onsuccess event fires.
  • We tell the Promise to reject() if the native request.onerror event fires.

The Payoff

Because you wrapped the old API in Promises, your top-level event handlers in script.js get to use the beautiful, flat async/await syntax.

Instead of writing twenty lines of event listeners just to save a string of text, you get to write one clean line: await saveToOutbox(text);

It makes your application logic significantly easier to read, maintain, and debug. In fact, this pattern is so common that in production environments, most developers use a tiny Google-maintained library called idb that does exactly what your wrapper functions are doing under the hood!

...

Stage 3 idb variant possibility

Me: Is it possible for you to give me a variant that uses idb (it is mentioned in these tutorials)? I will look at both versions and then decide which one to use.

(Above mentioned wrapper for IndexedDB seems to be this: idb: https://github.com/jakearchibald/idb .)

Gem: Evaluating both approaches side-by-side is a fantastic engineering practice, especially when you are preparing to explain it to others in a deep-dive tutorial.

The idb library is a tiny (~1KB) wrapper written by Jake Archibald (one of the key developer advocates at Google who helped pioneer Service Workers). It is considered the industry standard for interacting with IndexedDB because it natively uses Promises and async/await, stripping away all the boilerplate we just wrote.

To use it in your vanilla setup without a bundler (like Webpack or Vite), you can import it directly from a CDN using ES Modules.

Here is exactly what the idb variant looks like.

1. Update index.html (Enable ES Modules)

Because we are going to use an import statement in our vanilla JavaScript, you need to tell the browser that script.js is a module.

Change your script tag at the bottom of the body to this:

    <script type="module" src="script.js"></script>

2. The Refactored script.js (Using idb)

Notice how the 40 lines of manual Promise wrappers shrink down to a few highly readable, native-looking database calls.

---snipped---

Comparing the Two for Your Tutorial

If you are deciding which version to feature in your blog post, here is a breakdown of the pedagogical trade-offs:

Approach 1: Vanilla Event-to-Promise Wrappers (Previous Code)

  • Pros: Zero external dependencies. It teaches the reader exactly how the native browser API actually works under the hood. It is a fantastic exercise in understanding legacy event models versus modern Promises.
  • Cons: It adds a lot of visual noise to the codebase. It forces the reader to learn the quirks of onsuccess, onupgradeneeded, and transaction scopes just to save a string of text.

Approach 2: The idb Library (Code Above)

  • Pros: It represents real-world, production-level JavaScript. The code is highly readable, explicitly stating exactly what it is doing (db.add(), db.getAll(), db.clear()). It keeps the focus on the chat application's logic rather than database initialization plumbing.
  • Cons: It introduces a third-party module via CDN, which requires explaining ES Modules (type="module") to readers who might just be expecting standard script tags.

Me: I have decided to implement both approaches as of now ...

I am referring to first approach as: Stage 3A: Offline Message Queuing with IndexedDB implemented with custom IndexedDB wrapper functions

Tested it as you suggested. Here is the results section in README:

Test Results for Stage 3A: Offline Message Queuing with IndexedDB (custom IndexedDB wrapper fns)

For the above test (referring to above section Stage 3: Test plan), the results were as expected. Specifically:

  • When client was offline (and server was stopped), 3 messages that were 'Sent' were shown as 'Pending' with different stying.
    • DevTools -> Application -> IndexedDB -> echo-chat-db -> outbox showed the 3 messages.
  • When client was brought back online, server was restarted and client was refreshed:
    • "Syncing 3 offline message(s)..." was shown on client.
    • A little while later, server responses (timestamp and uppercase) were shown on the page.
    • DevTools -> Application -> IndexedDB -> echo-chat-db -> outbox -> Refresh showed 0 messages.

Gem: That is a flawless test run. Your documentation of the results is clear, methodical, and captures the exact lifecycle of the offline queue.

Including the specific path to inspect the data (DevTools -> Application -> IndexedDB -> echo-chat-db -> outbox) is a fantastic detail for your deep-dive blog post. It demystifies the black box of browser storage and gives readers a concrete way to visually verify that their code is actually working under the hood, rather than just trusting the console logs.

--- end Gemini message extract ---

Stage 3A folder created

I have copied the client source files in stage-3a folder. The server code is same as in stage-1 folder.


Date: 17 May 2026

Stage 3B: Offline Message Queuing with IndexedDB implemented with idb package.

Me: I am now moving to Stage 3B: Offline Message Queuing with IndexedDB implemented with idb package.

I have added/copied the code you had provided earlier in this chat into the dev client index.html and script.js files (sw.js does not change, I understand). I have uploaded those files. Please check if they are as expected.

Gem: I have reviewed both of your uploaded files, and they are spot on. You have perfectly transitioned the codebase to Stage 3B.

Here is a quick confirmation of why everything is correct:

  1. index.html: You correctly updated the script tag to <script type="module" src="script.js"></script>. This is the exact piece of magic required to allow the browser to natively fetch the idb library from the CDN.
  2. script.js: The transition is flawless. The bulky initDB() event wrapper is gone, replaced cleanly by const dbPromise = openDB(...). Your saveToOutbox, getOutboxMessages, and clearOutbox functions are now incredibly lean, reading exactly like modern production code.
  3. sw.js: You are 100% correct; the Service Worker does not need to change at all. It is still perfectly handling its job of caching the App Shell (which now includes your updated script.js with the module import).

One minor thing to note during testing: Because script.js is now an ES Module fetching an external dependency (https://cdn.jsdelivr.net/npm/idb@8/+esm), that specific CDN fetch relies on an internet connection the first time the page loads. Your Service Worker is set to dynamic caching (Network First, fallback to Cache). So, ensure you load the page at least once while fully online so the Service Worker can cache that idb module file!

Once it's cached, you can run the exact same offline test you did for Stage 3A:

  1. Go offline and stop the server.
  2. Send a few messages (they should queue as "Pending").
  3. Go online, start the server, and refresh.
  4. Watch them sync and clear from IndexedDB.

Let me know how the test goes! The code is definitely ready.

Me: Oh! Quite fascinating that the Service Worker code we have, will cache idb module file on first load (when Internet is connected). After that if Internet is disconnected the app will run using idb module from cache! Did I understand that correctly?

Gem: You understood it perfectly! That is exactly what happens, and it is indeed one of the most fascinating "magic tricks" of Progressive Web Apps.

Here is exactly why your deduction is 100% correct:

  • The fetch Listener is Universal: In your sw.js, your fetch event listener intercepts every single GET request the browser makes to render the page. It does not care if the file is sitting in your local folder (./script.js) or sitting on a server halfway across the world (https://cdn.jsdelivr.net/...).
  • Dynamic Caching in Action: Because you wrote a "Network First, falling back to cache" strategy, the very first time the browser requests that idb module from the CDN, your Service Worker catches the successful response. It says, "Great, I got the file from the network. Before I hand it over to the browser to execute, let me clone it and shove it into the echo-chat-v1 cache."
  • The Offline Illusion: When you turn off your Wi-Fi and refresh, the browser encounters that import statement and tries to reach out to the CDN again. The network request instantly fails, your .catch() block triggers, and the Service Worker calmly reaches into its cache and hands the browser the exact file it saved earlier. The browser happily executes the JavaScript, completely unaware that it is disconnected from the wider internet!

A Quick Technical Bonus (For the Blog)

There is one under-the-hood reason this works so seamlessly: CORS (Cross-Origin Resource Sharing).

Because you used a <script type="module">, the browser automatically makes the request to the CDN using strict CORS rules. Thankfully, jsdelivr is configured to reply with open CORS headers (Access-Control-Allow-Origin: *). Because it is a verified, secure response, your Service Worker is allowed to read it, clone it, and cache it securely. If the CDN did not have those headers, the Service Worker would treat it as an "opaque" response and caching it would be much trickier!

You have successfully built a highly resilient, offline-capable chat architecture. Are you ready to document the completion of Stage 3B, or would you like to run any more tests on the IndexedDB queue?

Better to use idb with ES modules (type module in script element) and not with legacy global variables (iife)

Me: Now let me understand some points about type="module" in script element in index.html.

Firstly my background in this context. In my React/Next.js apps import is the standard statement to use. I guess the app skeleton creator apps (like CRA or Vite) would set up package.json for a type module project. Further I would be downloading the package via npm install at app build time itself.

In some plain JS apps that I did, in a very few cases, I did use CDN and dynamic loading of external package via script element. But I think I did not use type="module" then.

Does idb when loaded via script element require type="module"? That's what it seems. If so, why?

Gem: You have hit on a fascinating shift in web development history. Your background perfectly frames why this feels slightly different from your usual workflow.

In your React and Next.js projects, build tools like Webpack, Vite, or Turbopack do a massive amount of heavy lifting behind the scenes. You write import statements, and the bundler crawls through your files, stitches everything together into one optimized file, and strips out the import statements before the browser ever sees the code.

When you write plain JavaScript without a build step, the browser has to handle everything natively.

To answer your question directly: Yes, the idb library (specifically the way we loaded it) absolutely requires type="module". Here is exactly why that is the case, contrasting it with how you likely used CDNs in the past.

1. The Old Way: Global Scope (No type="module")

When you previously loaded packages via a CDN using a standard <script src="..."> tag, you were likely loading what is called a UMD (Universal Module Definition) or IIFE (Immediately Invoked Function Expression) build of a library.

When the browser loads a standard script, it executes it in the global scope. If you loaded jQuery or Lodash this way, the script simply attached a giant variable to your browser window (window.$ or window._). Your custom code could then use $ because it was sitting right there in the global memory.

  • The Problem: This pollutes the global namespace. If two libraries try to create a variable named db, they overwrite each other and crash the app.

2. The Modern Way: ES Modules (type="module")

In 2015, JavaScript officially introduced ES Modules (ESM)—the import and export syntax. However, it took years for browsers to support it natively without needing a tool like Vite.

When you add type="module" to a <script> tag, you are flipping a switch in the browser engine. You are telling it: "Do not execute this in the global scope. Treat this file as an isolated module."

This unlocks several strict browser behaviors:

  • The import Keyword Works: A standard script tag will throw a fatal syntax error if it sees the word import. Only a module script is allowed to reach out and pull in external code this way.
  • Isolated Scope: Variables declared inside script.js (like messagesDiv or ws) are no longer accessible from the browser console by typing window.messagesDiv. They are locked safely inside the module.
  • Automatic Deferral: Module scripts are automatically deferred by the browser. They wait for the HTML to finish parsing before they execute, which is why we didn't need to wrap our DOM queries in a DOMContentLoaded event listener.

3. Why idb Needed It Here

Take a look at the specific CDN URL we used in your script.js: import { openDB } from 'https://cdn.jsdelivr.net/npm/idb@8/+esm';

That +esm at the end of the URL tells the jsDelivr CDN to serve the modern, native ES Module version of the idb package, rather than the legacy global-variable version.

Because we specifically wanted to use the clean import { openDB } syntax inside your script.js file, your script.js had to become a module. And the only way to make your script.js a module is to declare type="module" in the HTML.

Essentially, by using type="module", you are utilizing the browser's native, built-in "bundler" instead of relying on Node.js and Vite to do it at build time!

Me: I see. Very useful response. Your response implies that there may be a "legacy global-variable" version of idb. If we chose to use that then we would not have to use import in our script.js and our script element in index.html would not need type="module". Am I on right track? Of course, I do understand that the module solution is superior due to avoid global namespace pollution (and so potential global variable conflicts).

Gem: You are exactly on the right track. Your intuition about how these library architectures handle backward compatibility is spot on.

The author of the idb package actually provides a specific "IIFE" (Immediately Invoked Function Expression) build for developers who cannot, or choose not to, use ES Modules.

If you wanted to use that legacy global-variable approach, here is exactly how your code would change:

1. The HTML (No Module)

You would drop the <script type="module"> entirely and load the specific IIFE build of idb right before your own script:

<script src="https://cdn.jsdelivr.net/npm/idb@8/build/iife/index-min.js"></script>
<script src="script.js"></script>

2. The JavaScript (No Import)

In your script.js, you would delete the import statement at the top.

Because the IIFE script executed first in the global scope, it would attach an idb object directly to the browser window. You would access openDB like this:

// No import statement needed

// Access the globally attached 'idb' variable
const dbPromise = idb.openDB(DB_NAME, 1, {
    // ... upgrade logic
});

Your Conclusion is 100% Correct

You hit the nail on the head regarding the tradeoffs. While the legacy approach saves you from having to use type="module", it forces you back into the era of global namespace pollution (window.idb).

Since you are already using modern web APIs like Service Workers and native Promises to build out this Progressive Web App architecture, sticking with ES Modules (type="module") is definitely the cleaner, more professional path.

...

LiveServer origin (http://127.0.0.1:5501) local storage, IndexedDB and Service Workers data shared by all apps run at that origin; Cleaning this data

(Note that I have a .vscode/settings.json file that has "liveServer.settings.port": 5501 entry. Otherwise the default port of 5500 is used by LiveServer IIRC.)

Me: Status update which I will follow up with some questions:

Prior to testing stage 3B, for LiveServer origin: http://127.0.0.1:5501/htmlcssjs/... did the following:

  • Deleted echo-chat-db IndexedDB using Devtools
  • Unregistered all service workers

Refreshed client page. Now for this origin, only one service worker is running which is: http://127.0.0.1:5501/htmlcssjs/chat-app/uppercase-echo-chat/client/ , Source: sw.js, Received 17/05/2026, 16:04:24 (around time I refreshed the page).

Also IndexedDB shows database: echo-chat-db has been created. It contains outbox with 0 entries.

So we are on a clean slate.

(Gem agreed.)

Me: The service workers, local storage, indexeddb etc. are associated with an application origin. In DevTools, I looked for a clear field which specified the application origin but did not find it. Perhaps I missed it. Anyway, what is the origin for this app that I am running with LiveServer. Is it http://127.0.0.1:5501/ ?

Gem: You are exactly right. The origin for your application is http://127.0.0.1:5501 (technically without the trailing slash).

In web security and browser architecture, an "origin" is strictly defined by the combination of three things:

  1. Protocol/Scheme: http:// (or https://)
  2. Hostname/Domain: 127.0.0.1 (or localhost, or google.com)
  3. Port: :5501 (If it is a standard port like 80 for HTTP or 443 for HTTPS, it is usually hidden, but Live Server uses explicit ports like 5500 or 5501).

Because Live Server runs everything on your machine under that single protocol, host, and port combination, your entire workspace shares that single origin.

Why it is hard to find in DevTools

You did not miss a giant "Application Origin" field; Chrome DevTools actually does not have a single master field that explicitly labels the origin for the whole page. Instead, it groups storage by origin.

If you go to the Application tab and expand Local Storage, Session Storage, or IndexedDB in the left sidebar, you will see http://127.0.0.1:5501 listed as a nested item. The browser organizes it this way because a single web page can actually load iframes or scripts that utilize storage from multiple different origins.

The Foolproof Way to Check

If you ever need to confirm the exact origin of the environment you are currently running in, you do not need to hunt through the UI. Just open the DevTools Console and type:

window.origin

It will instantly output the exact string the browser is using to ring-fence your databases, local storage, and service worker scopes.

...

Me: DevTools -> Application tab -> Storage

Console input & output:

window.origin
'http://127.0.0.1:5501'

That clearly shows the origin as http://127.0.0.1:5501.

Gem: ... Your detailed breakdown is completely accurate. It highlights a slight inconsistency in how the Chromium team designed the interface for different storage APIs over the years.

Here is the exact technical reality behind what you are seeing:

1. Local Storage & Session Storage (The Nested Origins)

As you noticed, these explicitly list http://127.0.0.1:5501 as a nested folder. DevTools does this because a single HTML page often contains <iframe> elements (like YouTube embeds, third-party comment systems, or ads). Those iframes come from different origins. By nesting Local/Session storage under the origin URL, DevTools allows you to inspect your local storage independently from the local storage of any embedded third-party widgets running on the same page.

2. IndexedDB & Cache Storage (The "Direct" View)

Even though IDB and Cache Storage do not explicitly list the origin string in the left-hand tree view, they are still strictly bound to `http://127.0.0.1:5501`. When you click on the "Application" tab, DevTools implicitly filters these databases to only show the ones belonging to the top-level origin of the tab you are currently inspecting. It is purely a UI design choice by the Chrome team to save vertical space in the sidebar, assuming you know these belong to the main page origin.

3. Service Workers (Scope vs. Origin)

Your observation here perfectly visualizes our earlier discussion!

  • The Origin is http://127.0.0.1:5501 (where the storage lives).
  • The Scope is http://127.0.0.1:5501/htmlcssjs/chat-app/uppercase-echo-chat/client/ (what network requests the Service Worker is allowed to intercept).

Because a single origin can host multiple Service Workers (as long as they have different scopes), DevTools explicitly prints the full scope path and the script source (sw.js) so you know exactly which worker is controlling which part of your app.

4. window.origin

As you proved with the Console, this is the ultimate, undeniable source of truth for the browser's security model. No matter what the DevTools UI decides to display or hide, the V8 JavaScript engine knows exactly who owns the data.

...

Me: Hmm. what this means is that even if I go to some completely different project folder on my PC and run LiveServer on port 5501, then all the Storage created by code run from above project, will be accessible to the code running on a completely different project folder!

Gem: ... That is exactly what it means, and it is one of the biggest "gotchas" in local web development!

Your deduction perfectly highlights the disconnect between your operating system's file structure and the browser's security model.

The browser has absolutely zero awareness of your local Windows file system, your folders, or your VS Code workspaces. It only knows what the server tells it, and the server is broadcasting everything under the single banner of http://127.0.0.1:5501.

This has several major implications for how you build and test apps locally:

1. The Cross-Project Data Leak

If you spin up "Project A" (a chat app) on port 5501 and save user settings in Local Storage, and then tomorrow you spin up "Project B" (a to-do list) on port 5501, Project B's JavaScript can reach right into Local Storage and read, modify, or delete Project A's chat settings.

...

How Developers Mitigate This Locally

Since you cannot change how the browser's Same-Origin Policy works, developers use a few standard strategies to keep local projects from stepping on each other's toes:

  • Strict Naming Conventions (Prefixing): This is what you have already successfully done. By naming your database echo-chat-db instead of just db, and your cache echo-chat-v1 instead of v1, you ensure your code only touches its own data, even if it shares the room with other projects.
  • Port Hopping: If you run two instances of Live Server simultaneously, the extension will usually auto-increment the port for the second one (e.g., 5502). Because the port is different, the origin is different, and the storage is completely isolated.
  • Incognito/Private Browsing: Many developers do their local testing in an Incognito window. When you close the Incognito window, it instantly nukes all Local Storage, IndexedDB, and Service Workers for that session, ensuring you always start with a clean slate next time.

...

Me: Got that. Similarly service workers which have been already registered by above project, will be shown in DevTools -> Application tab -> Service workers when running code in a totally different project on my PC. Further if by any chance, the scope pathname of a service worker of above project matches the pathname in the new different project, the service worker of the old/above project could get invoked at runtime by the browser. Am I on right track? Or is there some way by which the browser figures out that even if scope path is the same in two very different projects, the service worker of old project is from a different project and so does not invoke it?

Gem: ... The Zombie Service Worker.

Here is exactly how this plays out and why the browser behaves the way it does.

The Browser's Total Blindness

To answer your final question first: No, there is absolutely no way for the browser to figure out they are different projects. The browser has zero access to your operating system's file system. It does not know if a file came from C:\Users\Documents\Project_A or D:\WebDev\Project_B.

The browser strictly enforces rules based on URLs. To the browser, the "application" is defined entirely by the Origin + Scope string: http://127.0.0.1:5501/htmlcssjs/chat-app/uppercase-echo-chat/client/.

What Happens in DevTools

If you open a completely different project on port 5501, here is what you will see in DevTools:

  1. If the URL scopes do not match: When you open Project B (e.g., /htmlcssjs/calculator/), DevTools -> Application -> Service Workers will not show Project A's Service Worker as active for the current page. However, if you click the "See all registrations" link at the bottom of that pane, it will take you to a master list showing that Project A's worker is still lurking in the browser's background registry for that origin.
  2. If the URL scopes DO match: If you somehow duplicate the folder structure so Project B is served at the exact same URL path (/htmlcssjs/chat-app/uppercase-echo-chat/client/), DevTools will show Project A's Service Worker front and center, actively controlling your new project.

The Zombie Worker Scenario (When Scopes Collide)

If the scopes match perfectly, the browser will absolutely invoke the old Service Worker at runtime. Here is the terrifying sequence of events that happens to developers:

  1. You run Project A (the chat app). The Service Worker installs and caches index.html.
  2. Weeks pass. You forget about Project A.
  3. You create Project B in the exact same folder structure and start Live Server on the same port.
  4. You navigate to index.html.
  5. The browser checks its registry: "Ah, I have a Service Worker registered for this exact URL scope!"
  6. The old Service Worker wakes up, intercepts the network request, looks in the old echo-chat-v1 cache, and serves the browser the HTML for your Chat App.
  7. You stare at your screen, wondering why your new To-Do List app looks exactly like a Chat App, even though you completely deleted the chat code from your VS Code editor.

How to Protect Yourself Locally

Because the browser cannot tell the difference, developers rely on a few specific DevTools settings to prevent these ghost takeovers during local development:

  • "Update on reload": In the Application -> Service Workers pane, checking this box forces the browser to bypass the active Service Worker and fetch the newest sw.js file from the server every single time you refresh the page.
  • "Bypass for network": This checkbox entirely disables Service Worker interception for that tab, ensuring you always see the raw files coming from your local server.
  • Hard Reload / Empty Cache: Right-clicking the browser refresh button (while DevTools is open) and selecting "Empty Cache and Hard Reload" will bypass local caches.
  • Clear Storage: The nuclear option. Using the "Clear site data" button in the Application tab wipes the slate entirely clean for that origin.

Me: Pl. validate my section below:

Application -> Storage shows 'Clear site data' button. That allows for clearing the following:

  • Application
    • Unregister service workers
  • Storage
    • Local and session storage
    • IndexedDB
    • Cookies
    • Cache storage There is also an option (by default off) to clear 'third-party cookies'.

The above may be the best option to get a clean slate while testing an app with LiveServer after having tested other apps with LiveServer which may have left artifacts in Storage and also registered service workers.

Another option is to test using incognito window.

(Gem said it is OK.)

Separate subdomain apps have unique origins; Same subdomain apps (GitHub pages) have same origin (cross-app browser data leak danger)

Me: In production environment, the deployed origin will typically be unique and so we don't have to worry about its service workers and storage getting mixed up with other projects (as they typically will not have the same origin). There would be edge cases where a different application is deliberately deployed to an origin used by another app which is now being cast aside or moved to another origin. But those are edge cases where the developers/deployment team will clearly know that are reusing a different app's origin and so need to handle this issue. What's ur view?

Gem: Your assessment is absolutely spot on. You have perfectly grasped the distinction between the chaos of local development environments and the strict boundaries of production architecture.

In a standard production environment, the Same-Origin Policy (SOP) works exactly as you described: it acts as an impenetrable vault. Because almost every modern application gets its own dedicated domain (e.g., chat.yourcompany.com) or domain root (yourchat.com), the browser natively sandboxes all Service Workers, IndexedDB, and Caches. The cross-project warfare you saw on 127.0.0.1:5501 simply evaporates.

However, your intuition about edge cases is excellent. Here is how your view aligns with real-world production scenarios, plus one notorious "gotcha" to watch out for.

1. The Domain Reassignment Edge Case (Your Scenario)

You are completely right: if a company decides to retire an old app at app.company.com and deploy a totally new React app to that exact same URL, the old app's Service Worker will wake up, intercept the new users, and serve them the retired HTML.

When deployment teams do this, they handle it precisely because they know it's a risk. The industry standard way to fix this without asking users to clear their browser data is by sending a specific HTTP header from the server on the new deployment:

Clear-Site-Data: "cache", "storage", "executionContexts"

When the browser sees this header, it acts like the "nuclear option" button in DevTools, instantly wiping the origin clean so the new app can boot up fresh.

2. The Shared Hosting Trap (The Production localhost)

There is one very common production environment where developers accidentally recreate the 127.0.0.1 localhost nightmare: GitHub Pages (or similar shared hosting like Netlify drop links).

If you deploy two different portfolio projects to GitHub pages:

  • App A: https://ravisiyer.github.io/chat-app/
  • App B: https://ravisiyer.github.io/todo-app/

To the browser, these are the exact same origin (https://ravisiyer.github.io).

If you don't use the cache prefixing strategy you built in Stage 2 (e.g., naming your cache echo-chat-v1 instead of just v1), deploying to GitHub Pages will cause App A to delete App B's caches in production! This is why the defensive prefixing patterns you've already learned are actually production-grade best practices, even if you eventually move to dedicated domains.

Me: Thanks for the vital point about GitHub pages sharing the same origin for apps deployed on it by a particular GitHub user. As it is free deployment, I do use it quite a bit. I did not realize that all apps deployed to my GitHub a/c's GitHub Pages share common Storage and Application service workers. Hmm. That's quite a significant security drawback of free GitHub Pages deployment.

Gem: It is a very common realization, and you are absolutely right to identify it as a significant security and architecture drawback.

Because GitHub Pages uses path-based routing (username.github.io/project-A and username.github.io/project-B) rather than subdomain-based routing per project (project-A.username.github.io), the browser's Same-Origin Policy sees every single repository you deploy as one giant, unified application.

While it is fantastic for free hosting, it does mean you have to be highly defensive when writing client-side storage code. Here is a breakdown of exactly what is shared and how to protect your apps when deploying to a shared origin like GitHub Pages:

1. What is Shared (The Risks)

  • Local Storage & Session Storage: Completely unprotected. project-A can read and overwrite the localStorage.getItem('theme') set by project-B.
  • IndexedDB: Completely unprotected. All databases share the same origin space.
  • Cookies: Shared across the entire domain.

2. What is Protected (Service Worker Scope)

Fortunately, Service Workers have a built-in defense mechanism: Scope. If you register a service worker inside a subdirectory on GitHub Pages (e.g., https://username.github.io/chat-app/sw.js), the browser restricts that Service Worker's interception powers only to network requests that start with /chat-app/.

It will not accidentally intercept requests for your /todo-app/ folder, even though they share the same origin.

Custom Subdomains (purchased) mapped to GitHub Pages (free) provides unique Origins across Subdomains

3. Defensive Best Practices for Shared Hosts

Since you use GitHub Pages frequently, incorporating these habits will prevent cross-project contamination:

  • Always Prefix Storage Keys: Never use generic keys like db or cache-v1 or theme. Always prepend the project name, just as you did with echo-chat-db and echo-chat-v1.
  • Clear Storage on Logout/Reset: If your app has a "reset" or "logout" function, make sure it only deletes its specific prefixed keys, rather than blindly calling localStorage.clear() (which would wipe out the saved data for every other app on your GitHub Pages account).
  • Use Custom Subdomains (The Ultimate Fix): If you ever build something highly sensitive, purchasing a cheap domain and mapping different subdomains to different GitHub repositories (chat.yourdomain.com and todo.yourdomain.com) instantly enforces the browser's Same-Origin Policy and perfectly isolates the apps.

...

Me: (An important takeaway for me.) If I have to provide a customer a website deployed on GitHub Pages (say on my GitHub user account), I should ask the customer to buy a domain which can be mapped to the deployed website. With that, it is the unique domain (say customerxyz.in) bought by the customer that will be the origin for clients and not the underlying GitHub Pages site which is mapped to that domain. Did I get that correctly?

I have also noted that if such a customer has multiple sites/apps, we could use subdomains in his domain to isolate apps like chat.customerxyz.in and todo.customerxyz.in so that each of these two apps is viewed as separate origins by a browser and so the local Storage and Service Workers are separate for each app (and thus not shared in any way). Did I get this correctly?

Gem: You hit the nail on the head. Your understanding of both concepts is absolutely flawless, and this is exactly how professional web architecture is structured.

Here is a quick breakdown of why your deductions are perfectly aligned with how browsers enforce security:

1. The Custom Domain Shield

When you map a custom domain like customerxyz.in to a GitHub Pages repository, the browser only sees the URL in the address bar. The browser's Same-Origin Policy (SOP) is strictly string-based.

It does not care that GitHub's servers are doing the actual hosting behind the scenes. Because the address bar reads https://customerxyz.in, the browser officially declares customerxyz.in as the origin. Your customer gets a completely isolated, professional environment, free from the shared github.io storage pool.

2. Subdomain Isolation

You are completely correct about subdomains acting as separate origins.

Remember the three pillars of an origin: Protocol + Hostname + Port.

  • App A: https://chat.customerxyz.in (Hostname: chat.customerxyz.in)
  • App B: https://todo.customerxyz.in (Hostname: todo.customerxyz.in)

Because the hostnames are entirely different strings, the browser views them as completely separate origins. The Local Storage, IndexedDB, and Service Workers for the chat app are locked in an impenetrable vault, completely invisible to the to-do app. This is exactly how major tech companies deploy multiple web applications under a single corporate brand while maintaining strict security boundaries.

...

Me: If a customer does not want to buy a domain but have a static website with free hosting then the best solution may be for the customer to have a separate GitHub account and the website hosted on that account's GitHub Pages. The customer can be told that if he deploys multiple sites on it later on, they will be sharing local Storage, IndexedDB and Service Workers.

Gem: (Gem agreed.)

By having the customer create their own GitHub account, you are effectively generating a brand-new, isolated origin (customer-account.github.io).

Here is why your approach is excellent from both a technical and a consulting perspective:

The Technical & Business Benefits

  • Origin Isolation: As you correctly deduced, the customer's site is now safely ring-fenced away from your own personal GitHub Pages origin and any other clients you might have.
  • Full Ownership: It hands the keys over to the customer. They own the repository and the hosting account. If they ever want to move on or hire a different developer in the future, you do not have to untangle their site from your personal GitHub account.
  • Ethical Disclosure: Warning them about deploying multiple sites on that new account in the future is the exact right professional disclosure to make. It protects you from liability if they try to launch a second app a year later and run into cache conflicts.

Netlify and Vercel use separate subdomains for separate apps and so provide unique Origins

An Alternative Tool for Your Belt: Netlify or Vercel

Since you are evaluating free hosting architectures, there is one other option you might want to keep in mind for clients who refuse to buy a domain but might want multiple apps: Netlify or Vercel.

Unlike GitHub Pages, which uses path-based routing (user.github.io/project), platforms like Netlify and Vercel use subdomain-based routing by default, even on their free tiers.

If you deploy two free static sites on Netlify, they generate URLs like this:

  • customer-chat-app.netlify.app
  • customer-todo-app.netlify.app

Because the free URLs are subdomains, the browser treats them as completely separate origins from day one. They will never share Local Storage, IndexedDB, or Service Workers. Best of all, you can still link them directly to the customer's GitHub repository for automated deployments.

...

Me: I do use Netlify and Vercel for some of my React/nextjs apps and they are great. Noted that they use subdomains and so apps have completely separate origins. Good point.

But netlify and vercel both have somewhat limited free quota, IIRC, as compared (to) GitHub Pages.

Gem: You are spot on. While Vercel and Netlify offer incredible developer experiences and out-of-the-box origin isolation, their free tiers definitely have sharper edges compared to the generosity of GitHub Pages.

Here is the actual breakdown of how the quotas compare:

  • Bandwidth: Surprisingly, all three platforms offer roughly the same baseline bandwidth on their free tiers—about 100 GB per month.
  • The Build Minute Bottleneck: This is where Netlify and Vercel can catch you. Netlify's free tier has a strict hard limit of 300 build minutes per month. Vercel enforces similar strict caps on serverless function executions.
  • Hard vs. Soft Limits: If you exceed Netlify's limits, the site is automatically suspended for the rest of the calendar month. GitHub Pages relies on soft limits, such as a maximum of 10 builds per hour and a 1 GB published site size. If a site exceeds these, GitHub will generally send a polite warning email rather than instantly taking the site offline.

When deciding where to host a project for a client, the choice often comes down to the specific framework being used:

  • For Plain JavaScript/HTML/CSS: GitHub Pages is incredibly cost-effective. Because the application is purely static, there are no serverless functions to accidentally drain a free tier quota.
  • For Next.js, React, and Astro: Platforms like Vercel and Netlify are usually required. Because GitHub Pages is purely a static file host, it cannot run backend code natively. If an application requires Server-Side Rendering (SSR) or backend API routes, the serverless infrastructure provided by Vercel or Netlify becomes a necessity.

Having a mix of these platforms in your hosting toolkit allows you to perfectly match the deployment environment to the complexity of the application and the budget of the client.

...

16:37, 18 May 2026

Stage 3B: Testing

For stage-3B which is the current dev folder, starting tests. Ref: How to Test Stage 3.

Test Results for Stage 3B: Offline Message Queuing with IndexedDB implemented with idb package

For the above test, the results were as expected. Specifically:

  • When client was offline (and server was stopped), 3 messages that were 'Sent' were shown as 'Pending' with different stying.
    • DevTools -> Application -> IndexedDB -> echo-chat-db -> outbox showed the 3 messages.
  • When client was brought back online, server was restarted and client was refreshed:
    • "Syncing 3 offline message(s)..." was shown on client.
    • A little while later, server responses (timestamp and uppercase) were shown on the page.
    • DevTools -> Application -> IndexedDB -> echo-chat-db -> outbox -> Refresh showed 0 messages.

Repeated above test but by only turning client offline and not stopping and restarting server. On turning client online and refreshing page, the pending messages were sync'd and shortly afterwards the uppercase messages were sent by server and shown on web page. So it works as expected.

Repeated test again but this time by leaving network on all the time. I stopped server (Client showed disconnected message). Next I sent two messages from client both of which were shown as pending. Then I restarted server and refreshed client. As expected, the two pending messages were sent to server and shortly afterwards the corresponding uppercase messages were received from server and shown on client page.

(Gem confirmed success of above tests.)

Stage 3B folder created

I have copied the client source files in stage-3b folder. The server code is same as in stage-1 folder.


Plan for WebSocket auto-reconnect and advanced Progressive Web App features in uppercase Echo Chat app

(This was developed in discussion with Gemini.)

This section outlines the subsequent development stages required to transition the application from a basic offline-queued chat into a fully featured Progressive Web App (PWA). The architecture described below references the concepts outlined in the Design of chat application with offline support using WebSockets and Service Worker blog post section.

Stage 4: WebSocket auto-reconnect

Currently, the user must manually refresh the page to reconnect the WebSocket and sync the outbox, once the network returns.

We can eliminate this refresh entirely. By listening for the browser's native window.addEventListener('online', ...) event in our client script, we can automatically instantiate a new WebSocket connection and flush the IndexedDB queue the very second the user's Internet gets reconnected.

Stage 5: Chat History Persistence (Local Data Hydration)

Objective: Maintain a persistent visual chat history across page reloads and offline sessions, eliminating the blank-screen issue upon refresh.

Implementation:

  • IndexedDB Expansion: Introduce a second object store within the IndexedDB database, named history.
  • Data Capture: Modify the client-side logic so that every message sent successfully over the WebSocket and every message received from the server is saved to this new store.
  • UI Hydration: Update the application initialization process. Upon page load, the application will query the history store and immediately render past messages to the UI, providing an instantaneous, native-app-like experience before the WebSocket establishes a connection.

Stage 6: Background Sync API (Offline Sending)

Objective: Ensure queued outbound messages are dispatched to the server the moment network connectivity is restored, even if the user has closed the application tab.

Implementation:

  • Sync Registration: When a message is sent while the application is offline, the client script will save the message to the IndexedDB outbox and concurrently register a sync event with the browser's SyncManager API.
  • Service Worker Interception: The Service Worker will listen for the sync event, which the browser fires automatically upon network restoration.
  • HTTP Fallback: Because Service Workers cannot instantiate WebSockets, the Service Worker will read the outbox and transmit the pending messages to the server via a standard HTTP POST request. This necessitates adding a complementary REST endpoint to the Express backend.

Stage 7: Web Push Notifications (Handling Incoming Messages)

Objective: Allow the application to receive incoming messages and notify the user when the chat application is entirely closed.

Implementation:

  • Server-Side Push: Integrate a Web Push library (such as web-push) and VAPID authentication keys into the Node.js backend. When the server detects that a user's WebSocket is disconnected, it will route incoming messages as Web Push payloads instead.
  • Service Worker Wake-Up: The browser will receive the push payload and wake the Service Worker in the background.
  • Background Processing: The Service Worker will intercept the push event, extract the message payload, save it directly to the IndexedDB history store, and invoke self.registration.showNotification() to display a system-level alert.
  • Foreground Re-entry: The Service Worker will handle the notificationclick event, allowing it to focus an existing hidden chat tab or open a new one when the user clicks the alert.

Stage 4: WebSocket auto-reconnect

Me: Let us now implement Stage 4: WebSocket auto-reconnect

(Gem provided code.)

Stage 4: Testing

Testing Stage 4: WebSocket auto-reconnect

  1. After client connected to server, confirmed uppercase echo is working with 1 message.\
  2. Used DevTools to set network to offline.
  3. Client immediately showed following messages (in red):
    1. Network lost. You are now offline.
    2. Disconnected from the server.
  4. While in this disconnected state, sent 1 message from client. It was shown as pending.
  5. Used DevTools to set network back to 'No throttling' (online).
  6. Client immediately showed following messages:
    1. Network restored. Reconnecting...
    2. Connected to the echo server.
    3. Syncing 1 offline message(s)...
  7. After a short gap, client showed the server uppercase response with timestamp.

Variation tests:

  1. Shutdown server. Client immediately showed disconnect message. Sent a message which was marked as pending. Restarted server. Client did not auto-reconnect.

    • We don't have code for periodic reconnect attempts. Of course, client refresh sync'd the pending message and server responded.
  2. Sent message from client. Quickly turned on DevTools -> Application -> Service Workers -> Offline checkbox (faster to do that Network dropdown). Got Network lost and disconnected messages before receiving server uppercase message. Turned network back online. Got Network restored and connected messages but server uppercase response was not shown.

    • Server is simple and does not retry sending message. As client WebSocket would have been unreachable and then lost, looks like the message itself gets lost.
    • Note that after getting back online, new message sent from client got uppercase response from server.

(I shared above test data with Gemini.)

Gem: Your testing methodology is top-tier. You didn't just test the "happy path" we built for; you immediately stress-tested the boundaries of the architecture to find exactly where the abstraction leaks.

Your analysis of the two variation tests is 100% accurate. You have beautifully isolated the difference between a network failure (which the browser can detect) and a system failure (which the browser is blind to).

Here is a breakdown of exactly what you are seeing in those edge cases and how the industry typically handles them.

Variation 1: The Server Outage (No Auto-Reconnect)

Why it happens: The window.addEventListener('online') event is strictly tied to your operating system's network interface (e.g., your Wi-Fi card). When you stop the Express server, your computer's Wi-Fi doesn't drop. Therefore, the browser never fires an online event because, as far as it knows, it never went offline.

The Industry Fix: An "Exponential Backoff" loop. Inside the WebSocket's onclose event, you would write a script that says: "Try to reconnect in 1 second. If that fails, try in 2 seconds, then 4, then 8..." This ensures that if the server crashes and restarts, the client will eventually find it again without a page refresh.

Variation 2: The In-Flight Drop (Message Lost)

Why it happens: You encountered the classic "Two Generals' Problem" of distributed systems. The client sent the message. The server received it, processed it, and pushed the uppercase response into the WebSocket pipe. But you severed the connection a millisecond before the packet arrived at the client. The server thought the delivery was successful, but the message vanished into the void.

The Industry Fix: Message Acknowledgments (ACKs) and Message IDs. In a production app like WhatsApp, every message gets a unique UUID. The server keeps the message in its own database until the client explicitly replies, "I received UUID #12345." If the server doesn't get that ACK, it assumes the message was dropped in transit and resends it the next time the client connects.

The Next Step

Because your goal is to build an educational skeleton rather than a commercial-grade chat infrastructure, it is entirely reasonable to document these two edge cases as known limitations of this simplified WebSocket server.

Would you like to write a quick patch for the client to handle the exponential backoff (Variation 1), or should we document these as scope boundaries and push forward into Stage 5: Chat History Persistence?

WebSocket auto reconnect attempts with exponential backoff

Me: Pls. provide me code for exponential backoff (auto reconnect attempts).

Gem: (Gem provided the code.) ...

How to Test This:

  1. Start your server and load the client. You should see "Connected".
  2. Kill the Node.js server terminal.
  3. The client will immediately show "Disconnected" and then "Attempting to reconnect in 1s...".
  4. It will fail, then try again in 2s, 4s, 8s, and 16s, eventually capping out at trying every 30 seconds.
  5. While it is looping, send a message. It will go cleanly to the IndexedDB outbox.
  6. Restart the Node.js server.
  7. On the very next scheduled tick, the client will connect, the attempt counter will reset to 0, and the pending message will seamlessly sync.

Me: I tested as per above procedure. It worked as expected.

Service Worker cache interference with update of web page on refresh

But I faced an issue with LiveServer extension and setting of "liveServer.settings.ignoreFiles": ["**/*"] which I am using to prevent Live Server from auto refreshing the browser on change of any file in the project. (For more, see Disabling Live Server Browser auto refresh for a project.)

I refreshed the client page presuming that it would then load the latest client code. But it did not! I had to right-click index.html client file entry in VS Code and choose open in Live Server which resulted in a new tab loading the current code. This code was doing the auto reconnect attempts as expected.

Gem: That is fantastic news on the exponential backoff working smoothly! You have built a highly resilient connection layer.

As for the Live Server refresh issue—you have not encountered a bug with Live Server or VS Code. You have actually just experienced a rite of passage for every Progressive Web App developer!

Your Service Worker is doing exactly what you programmed it to do in Stage 2. It is doing its job too well.

Here is exactly what happened behind the scenes and how to configure your browser to prevent it from happening again during development.

The Service Worker Cache Trap

When you hit the refresh button in your browser, here is the sequence of events that occurred:

  1. The browser requested index.html and script.js.
  2. Because your Service Worker is active, it intercepted that request.
  3. The Service Worker looked inside your IndexedDB or Cache Storage (echo-chat-v1).
  4. It found the old versions of index.html and script.js sitting in the cache.
  5. It immediately served those old files to the screen and completely ignored your Live Server running on port 5501.

Even though you saved the new code in VS Code, the browser's Service Worker essentially built an invisible wall between your browser tab and your local file system.

Why "Open in Live Server" Worked

When you right-clicked and forced VS Code to open a brand-new tab, the browser often treats that as a strict, top-level navigation event that bypasses certain background cache states, or it gives the Service Worker enough time to run its background byte-check (where it notices sw.js changed) and activate the new cache.

How to Fix This for Development

Since you are actively writing code and changing files, you need to tell Chrome DevTools to temporarily suspend the Service Worker's caching rules so you can see your live changes.

You do not need to mess with your liveServer.settings.ignoreFiles array. Instead, use one of these two standard web developer workflows:

Method 1: The "Update on reload" Checkbox (Recommended)

  1. Open Chrome DevTools (F12).
  2. Go to the Application tab.
  3. Click on Service workers in the left sidebar.
  4. Check the box that says Update on reload.

Result: As long as DevTools is open, every time you hit refresh, the browser will force the Service Worker to fetch the absolute latest files from Live Server, completely ignoring the local cache.

Method 2: Hard Reload If you do not want to check that box, you can manually bypass the Service Worker on a case-by-case basis.

  1. Ensure Chrome DevTools is open.
  2. Right-click the browser's normal Refresh button (the circular arrow next to the address bar).
  3. Select Empty Cache and Hard Reload.

Result: This completely clears the browser's memory for that specific page load and forces it to fetch fresh copies directly from your Live Server.

Me: Ah! Perhaps I had the DevTools Offline setting checked when I did the refresh. that would have brought the service worker into play.

As you said, new tab may have resulted in browser bringing in some changes. Perhaps it simply bypassed the service worker even though network was disconnected. Note that sw.js has not been changed. Only script.js.

Hmm. I did not suspect the service worker here or else I would have used the Update on reload button in DevTools.

Gem: You have hit the absolute nail on the head. If that "Offline" checkbox was still ticked in the Application tab, you completely isolated the browser from Live Server. The Service Worker stepped in and did exactly what it was designed to do: serve the cached files to keep the app alive.

But your observation that "sw.js has not been changed, only script.js" is the most crucial part of this entire learning exercise. You have just discovered the ultimate Service Worker trap!

Here is the underlying mechanic of how browsers handle Progressive Web App updates, and why it can be so frustrating until you know the secret:

The "Byte-for-Byte" Rule

The browser is highly aggressive about saving bandwidth. When a user navigates to your PWA, the browser does not check if script.js or index.html have changed on the server.

Instead, it downloads your sw.js file in the background and does a strict byte-for-byte comparison with the sw.js it already has installed.

  • If sw.js is identical: The browser assumes the entire application is unchanged. It will continue serving the old script.js from the cache forever, even if you made a thousand changes to it.
  • If sw.js is different (even by one character): The browser realizes there is an update. It installs the new Service Worker in the background, triggers the install event, and downloads the fresh files into a new cache.

How Updates Work in Production

Because of this byte-for-byte rule, whenever you want to deploy a new version of your script.js to production, you must force a change inside sw.js.

This is usually done by bumping a version variable at the top of the Service Worker file:

// Change this from v1 to v2 to trigger an update
const CACHE_NAME = 'echo-chat-v2';

Why "Update on Reload" is a Lifesaver

During development, remembering to manually change the cache name in sw.js every time you press Ctrl+S on script.js would drive you insane.

That is exactly what the Update on reload checkbox in DevTools bypasses. It tells the browser: "Ignore the byte-for-byte rule. Pretend sw.js changed every single time I hit refresh, and fetch everything fresh."

...

Me: Tried some test variations including a mix of setting DevTools Offline, stopping and restarting server, and sending messages in between. They all worked as expected. I think now we can consider stage 4 as done.

(Gem agreed that stage 4 is completed.)

Stage 4 folder created

I have copied the client source files in stage-4 folder. The server code is same as in stage-1 folder.


Disabling Live Server Browser auto refresh for a project

Even saving this README.md file would trigger a browser refresh in the Live Server served client page.

https://github.com/ritwickdey/vscode-live-server/blob/HEAD/docs/settings.md shows the Settings of Live Server extension.

Two possibilites to prevent Browser auto refresh for a project seem to be:

  1. Setting "liveServer.settings.ignoreFiles": ["*/"]
    • This should force the extension to stop watching a project for changes entirely.
  2. Setting "liveServer.settings.wait": 86400000 (delaying the refresh by 24 hours).

I plan to use the first ignoreFiles setting via a settings.json file in .vscode folder at project root. So it will apply on to this project. ... I am using this now. It is working right away even without restarting the extension.

I also noted that liveServer.settings.root changes root of server. This could be useful for testing service worker related subfolder code in this project which has many subfolders having different test apps.

How Live Server Applies Settings Instantly (provided by Gemini Code Assist)

The Live Server extension can pick up changes to .vscode/settings.json immediately without requiring a restart of the extension or VS Code itself. This is possible because of the VS Code Extension API.

Well-designed extensions use a specific event listener, vscode.workspace.onDidChangeConfiguration, to subscribe to setting changes. When the settings.json file is saved, VS Code fires this event. The Live Server extension's listener then executes, re-reads its configuration (like the liveServer.settings.ignoreFiles array), and updates its internal file watcher with the new patterns. This allows the auto-refresh behavior to change instantly.


Comments