Lecture
Progressive Web Apps (PWA) are web applications that use standard web technologies (HTML, CSS, JavaScript) yet behave like native applications on devices. They combine the best of websites and mobile apps.
Installation on the device The user can "install" a site as an app: it gets its own icon on the desktop or in the applications menu.
Offline operation With the help of Service Workers, PWAs can cache data and work even without an internet connection.
Native interface They launch in a separate window with no browser chrome, creating the feel of a real application.
Cross-platform support A single codebase works on different devices and operating systems (Windows, Android, iOS, macOS).
Automatic updates They update the same way websites do — with no need to download new versions from a store.
For business: cheaper than developing separate apps for iOS and Android.
For users: convenient access, less space taken up on the device, the ability to work offline.
For developers: a single codebase, easy maintenance and scaling.
| Element | Purpose |
|---|---|
| manifest.webmanifest | A JSON file with settings (name, icons, theme colour, start URL). |
| Service Worker | A script for caching, offline mode and push notifications. |
| HTTPS | A mandatory requirement for security and for Service Workers to work. |
| Responsive Design | Adapting the interface to different devices. |
| Criterion | Progressive Web App (PWA) | Kiosk mode |
|---|---|---|
| Purpose | Make a site resemble a native app that can be installed on a device | Restrict the device to working with a single app/site only |
| Manifest file | manifest.webmanifest describes the name, icons, colours and start URL | Usually not used; configuration is done through the OS or third-party applications |
| Installation | Installed voluntarily by the user (desktop icon, applications menu) | An administrator enables the mode, and the device launches only the selected application |
| Flexibility | Can be launched either as an ordinary site or as an app | A hard restriction: the user cannot go beyond the selected application |
| Offline mode | Supported via a Service Worker | Depends on the application; kiosk mode itself adds no offline features |
| Security | Requires HTTPS; push notifications and caching can be added | The main goal is physical and logical protection: preventing access to other device functions |
| Use cases | Handy for sites that want to feel "like an app" (shops, services, news portals) | Used in public terminals, interactive kiosks and self-service points |
A PWA is about convenience and universality: a site turns into an app.
Kiosk mode is about control and restriction: the device runs only one application, so that the user cannot do anything else.
This is the Web App Manifest — a JSON file that describes the parameters of a web application.
It tells the browser how the site should behave when installed on a device: name, icons, theme colour, screen orientation and so on.
Having such a file is a mandatory requirement for a site to be installable as an app on a smartphone or computer.
Type of applications: Progressive Web Apps (PWA).
Format: JSON with the MIME type application/manifest+json.
Purpose: turns a site into an "almost native" application — with an icon, full-screen mode and the ability to work offline.
Modern browsers (especially Chrome, Edge and others) can offer to install a site as a PWA — a Progressive Web App.
If the site meets certain conditions, the browser automatically shows an "Install app" button or even installs it by itself (on some systems).
The presence of manifest.json — a special file describing the application:
{ "name": "My App", "start_url": "/", "display": "standalone", "icons": [...] }
HTTPS support — the site must work over a secure protocol.
Service Worker — a script that manages the cache and network requests (for offline mode, for example).
The site behaves like an application (for example, with no visible address bar, with an icon and its own shell).
As a result, the browser creates an isolated application (WebApp) out of the page, launched separately from tabs.
There are several ways to tell these cases apart — from simple to advanced.
A PWA (an installed application) usually has no Referer (or has it as null).
An ordinary tab will have a Referer with the page URL.
On the server (a PHP example):
if (empty($_SERVER['HTTP_REFERER'])) { echo "Probably PWA or standalone app"; } else { echo "Browser tab: " . $_SERVER['HTTP_REFERER']; }
In JS you can determine whether the site is open as a PWA:
if (window.matchMedia('(display-mode: standalone)').matches) { console.log('Running as installed PWA'); } else { console.log('Running in browser tab'); }
You can pass this along in an AJAX request:
fetch('/api/check', { method: 'POST', headers: { 'X-From-PWA': window.matchMedia('(display-mode: standalone)').matches ? 'yes' : 'no' } });
A PWA can have its own scope, for example /app/, in which case all AJAX requests from it will carry the corresponding Origin and Referer.
If requests are handled through a Service Worker, you can look at:
DevTools → Application → Service Workers
The console or the network panel: if a request comes "from ServiceWorker", it is a PWA.
| Indicator | Ordinary tab | Installed PWA |
|---|---|---|
| Referer | Present | Often null |
| display-mode | browser | standalone |
| Service Worker | May be absent | Almost always present |
| Display | A tab with an address bar | A separate window |
| User-Agent | An ordinary browser | The same, but with no Referer |
One of the key features of a PWA (Progressive Web App) is caching.
Indeed, if a user has installed the site as an app, the browser may use the old version of the scripts even after the site has been updated.
Let's go through in detail why this happens and how to control it.
When a site becomes a PWA, it gains a Service Worker — a background script that:
intercepts all network requests,
can cache HTML, JS, CSS, images and API responses,
returns cached data instead of loading from the server.
So if your site has been updated but the user already has a Service Worker with cached versions —
they will keep seeing the old files (an old main.js, for example) until the Service Worker updates.
A Service Worker does not update instantly, but according to certain rules:
The browser checks whether service-worker.js has changed (by content or ETag).
If the file is different, a "new version" of the Service Worker is created.
The new version is installed, but is not activated until the user:
closes all tabs/windows of the app,
or restarts the PWA.
Only then is the cache updated.
This means updates can be delayed by several hours or even days.
Here is how it is done in practice:
Add a version number to the file names:
or add a query parameter:
The browser treats this as a new URL, so the cache is not used.
In service-worker.js you can set a strategy:
self.addEventListener('fetch', event => { event.respondWith( caches.open('my-cache-v3').then(cache => { return fetch(event.request).then(response => { cache.put(event.request, response.clone()); return response; }).catch(() => cache.match(event.request)); }) ); });
Note my-cache-v3 — when you change the version (v3 → v4), the old cache will be dropped.
In service-worker.js you need to add:
self.skipWaiting(); self.clients.claim();
This makes the new SW activate immediately, without waiting for the old one to be closed.
But be careful — if the user is doing something at the moment of the update,
a conflict between old and new caches may occur (JS errors, for example).
You can add an automatic check:
navigator.serviceWorker.getRegistration().then(reg => { if (reg) { reg.update(); // force a check for a new version } });
| Behaviour | Cause | Solution |
|---|---|---|
| Scripts are not updated | The Service Worker cache | Versioning or update() |
| The site loads offline | The PWA caches all resources | Configure the cache strategy |
| The user sees an old version | The SW has not activated | skipWaiting(), clients.claim() |
If the app does not use a Service Worker, caching behaves differently —
and it really can be confusing, especially with PWAs created "automatically" by the browser.
Let's go through it in detail:
When you install a site as an app (a PWA, or simply a "shortcut" via Chrome → "Add to desktop"),
the browser does the following:
Creates a separate container (WebApp) with a minimal shell.
Opens the site in display-mode: standalone — with no address bar.
Continues to use the ordinary browser cache — that is:
the HTTP cache (Cache-Control, ETag, Last-Modified);
caching of DNS and resources (JS, CSS, images);
local data (localStorage, IndexedDB, cookies).
In other words, in this case updates depend not on a Service Worker but on the standard HTTP caching rules.
There are several reasons:
Check that the server is not sending too "long" a cache lifetime.
For example, this is a bad example:
Cache-Control: public, max-age=31536000
This makes the browser keep JS files for a whole year.
Better:
Cache-Control: no-cache
or:
Cache-Control: max-age=0, must-revalidate
The browser may assume that main.js has not changed,
and take it from the local cache (especially in standalone mode).
The solution is to add versioning:
Chrome and Edge often cache resources more aggressively in app mode,
so that startup is instant.
In this case updates are only picked up after a forced check (Ctrl+F5)
or when the headers change.
If the user installed the app through Chrome,
the files may be stored in a system folder such as:
%LOCALAPPDATA%\Google\Chrome\User Data\Default\Web Applications\
Sometimes Chrome takes static resources (old JS versions) from there.
Deleting the app or its data solves the problem.
Open DevTools → Network.
Reload the page inside the app (Ctrl+R).
Look at the Size or Status column:
from disk cache / from memory cache → the old cache.
200 OK → the file was actually downloaded from the server.
You can also clear the cache for a specific domain:
chrome://settings/clearBrowserData
or for a specific site:
chrome://inspect/#apps
| Situation | What is cached | How to update |
|---|---|---|
| Without a Service Worker | The ordinary browser HTTP cache | Change the headers or the URL |
| With a Service Worker | A programmatic cache in JS | Update the SW and the cache version |
| An installed PWA | May keep it longer | Clearing the cache or a version in the URL |
This is an important nuance of how installed PWAs (Progressive Web Apps) behave.
Yes, the app will keep launching,
even if you remove the tag from the site.
But:
The browser will no longer be able to update the app's information (icon, name, theme colour, behaviour and so on).
When a user installs a PWA (or "adds a site to the desktop"), the browser:
Loads manifest.json;
Saves its contents in its own internal storage (in the user's profile);
Creates a system "shell" for launching the app (.desktop, a shortcut, an icon, and so on);
No longer depends on manifest.json being present on the server at startup.
That is, after installation everything is already local:
Icons — copied into the system;
Name — saved;
Launch URL (start_url) — recorded;
Display (display-mode: standalone) — fixed.
| Behaviour | Result |
|---|---|
| The app is already installed | ✅ Will keep working |
| The user opens the site in the browser | ❌ The "Install" button will disappear |
| Reinstallation | ❌ Impossible — there is no manifest.json |
| Updating the name, icon, etc. | ❌ Will not happen; the browser cannot check |
| Removing the site and installing it again | ⚠️ Impossible without a manifest |
Install the site as an app (via Chrome → ⋮ → "Install app").
After installation, delete manifest.json or the tag
Restart the app — it will open, because the data is stored locally.
Open the same site in a browser tab — the install button will no longer be there.
If manifest.json previously had a start_url, the app will always open at exactly that URL — even if the site now has different code or structure.
If the site is unavailable, the app will show a connection error (no Service Worker means no offline mode).
Chrome, Edge, Opera and other Chromium browsers all behave the same way.
Firefox is slightly different: its PWA support is weak overall, and it may fail to launch without a manifest after the profile is reloaded.
| Action | App behaviour |
|---|---|
| Remove | Works, but without updates |
| Delete manifest.json itself | Works, but cannot be reinstalled |
| Change manifest.json | The changes will not be picked up until reinstallation |
| Clear the site data (AppData / Storage) | The app will stop working |
Here are several reliable ways in JavaScript to determine that the user has opened the installed app (a PWA),
even if manifest.json no longer exists on the site.
The most reliable and simplest way is via window.matchMedia:
if (window.matchMedia('(display-mode: standalone)').matches) {
console.log(' Launched as an installed app (PWA)');
} else {
console.log(' Opened in a browser tab');
}
This works even without manifest.json,
because the browser remembers the display-mode when the app is installed.
Safari on iOS uses its own flag:
if (window.navigator.standalone) {
console.log('Installed app on iOS');
}
This flag exists only in Safari (not in Chrome/Edge).
To support all platforms:
function isRunningAsApp() {
return ( window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true );
}
if (isRunningAsApp()) {
console.log('The app is running in standalone mode');
} else {
console.log(' An ordinary browser tab');
}
Installed apps often have no Referer,
unlike ordinary tabs:
if (!document.referrer) {
console.log('Probably launched as a standalone app');
} else { console.log('Opened from another page or tab');
}
You can use this as an additional signal.
Sometimes an installed app may have a special start_url
(for example, /app/ or /home.html) that differs from the ordinary site.
You can do it like this:
if (window.location.pathname.startsWith('/app')) { console.log(' The app was launched from an app path'); }
This is handy if you set a start_url in manifest.json at installation time.
You can add this to your AJAX code:
fetch('/api/ping', { method: 'POST', headers: { 'X-From-PWA': isRunningAsApp() ? 'true' : 'false' } });
Then on the server (in PHP, for example) you will be able to tell them apart:
if ($_SERVER['HTTP_X_FROM_PWA'] === 'true') { // code for the app }
If you want to make sure the condition works:
Open the (installed) app.
Open DevTools → Console.
Paste the code from point 1.
It should print "Launched as an installed app (PWA)".
Let's go through in detail what start_url in manifest.json is,
and why it matters — especially if you are working with an installed app (a PWA).
start_url is the path (URL) from which the installed web application (PWA) is launched.
It is set in the manifest.json file, for example:
{ "name": "My Cool App", "start_url": "/app/home.html", "display": "standalone", "icons": [ { "src": "/icons/icon192.png", "sizes": "192x192", "type": "image/png" } ] }
When the user installs the site as an app, the browser:
saves this value (/app/home.html);
and afterwards always opens the app at exactly this URL,
even if the user clicks the shortcut rather than a link.
In other words, it is the "entry point" of your application.
Suppose your site is:
https://example.com/
and in manifest.json you specified:
"start_url": "/dashboard"
Now:
when the app is opened from the menu/shortcut, the browser will launch:
https://example.com/dashboard
but if the user simply opens the site manually, they will see https://example.com/
This means it lets you separate the behaviour of:
the site for ordinary visitors;
and the app for users who installed it.
Because you can:
point the app at a different interface (/app/, /panel/, /offline.html);
use different versions of the site;
or serve a different JS file / a different layout,
specifically for users who installed the app.
You can use start_url together with a JS check:
if (window.matchMedia('(display-mode: standalone)').matches) { console.log('App started from', window.location.href); }
And if window.location.pathname matches the value from start_url,
then the user launched the installed app rather than simply opening the site.
In brief:
| Property | Purpose |
|---|---|
| start_url | The URL from which the app launches after installation |
| Where it works | In Chrome, Edge, Opera, Android WebView |
| What it does | Defines the "entry point" for the PWA |
| Can be used for | Separating site and app logic |
Let's work out what happens if the start_url parameter is not specified in manifest.json.
The short answer
If manifest.json has no "start_url" field,
then by default the browser uses the URL of the page
from which the user installed the app.
When the user clicks "Install app" (or "Add to desktop"),
the browser looks at:
"Which page am I being installed from right now?"
If manifest.json has no start_url, it simply saves the current address
and makes it the launch point.
{ "name": "MyApp", "display": "standalone" }
https://example.com/index.html
After installation the app will always launch from
https://example.com/index.html
If the user installed the app from another page, for example:
https://example.com/app/dashboard
Then when the app is launched it will always open at exactly this URL,
even if you later change the site's structure.
Unpredictable behaviour
If different users install the app from different pages,
each of them will have their own "entry point".
One will land on /, another on /shop/, a third on /app/dashboard.
Problems when updating
If you change the routes or delete the page
from which the app was installed,
the user may get a 404 error on launch.
You cannot manage the app's start point centrally,
because the URL depends on where the user was at the time of installation.
It is better to always specify start_url explicitly, for example:
{ "name": "MyApp", "short_name": "App", "start_url": "/app/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#2196f3", "icons": [ { "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" } ] }
That way you control where the app starts for all users,
and you can prepare the interface specifically for display-mode: standalone.
| Scenario | What will happen |
|---|---|
| start_url specified | The app always launches from this address |
| start_url not specified | The app launches from the address where it was installed |
| The page has been deleted | ❌ The app stops working (404 error) |
| Recommendation | ✅ Always specify start_url explicitly |
and
There is no functional difference between:
and
Both options are completely equivalent for the browser.
The main thing is that:
the file has the correct MIME type (application/manifest+json);
and contains valid JSON.
The browser does not care what the file is called —
manifest.json, manifest.webmanifest, or even myapp.manifest.
It goes by:
rel="manifest" — this is the signal that it is a PWA manifest;
the correct MIME type (HTTP header);
correct JSON syntax.
The .webmanifest extension is the new recommended standard from the W3C and Google Chrome.
It was introduced in order to:
distinguish a PWA manifest from ordinary JSON files (config.json, for example);
allow servers to set the MIME type correctly based on the extension.
Specification:
https://www.w3.org/TR/appmanifest/
Browsers expect the server to serve the file with the type:
Content-Type: application/manifest+json
If the server serves application/json, Chrome and Edge will still accept it,
but they may show a warning in DevTools.
If something like text/plain is served, the manifest is ignored.
Open your manifest.webmanifest in the browser and look in DevTools → Network → Headers.
The headers should contain:
content-type: application/manifest+json
If you see application/json, it is not critical, but you can fix it in .htaccess:
AddType application/manifest+json .webmanifest
| Criterion | .json | .webmanifest |
|---|---|---|
| Works in browsers | ✅ Yes | ✅ Yes |
| Recommended by the standard | ⚠️ No | ✅ Yes |
| Requires the application/manifest+json MIME type | ✅ Desirable | ✅ Mandatory |
| Difference in functionality | ❌ No | ❌ No |
| Why use .webmanifest | For clarity and compatibility with future standards |
Use:
and set the MIME type on the server:
That way it will be perfect by the standards and free of warnings in Chrome.
This is the most vulnerable point of a PWA if it does not use a Service Worker.
Let's work out clearly what will happen.
The user installed the site as an app (via a shortcut).
The site now launches in a separate window (display-mode: standalone).What happens if:
the server is unavailable (a 500 or 404 error, the host is not responding),
or there is no internet?
The app will not launch.
When such a shortcut is launched, the browser simply tries to load the same URL (from start_url or from the installation page).
If:
the server does not respond,
there is no network connection,
or the page has not been loaded from cache,
then the user will see the standard browser error:
"No internet connection"
"This site can't be reached"
"ERR_INTERNET_DISCONNECTED"
Even if the app is installed, it is not self-contained without a Service Worker.
Without a Service Worker, the browser is not allowed to use cached versions arbitrarily.
It goes straight to the network, and if the server is unavailable, everything falls over.
A Service Worker, on the other hand, is like a "proxy inside the browser" that can say:
"No network? Then here is a saved page from the cache for you."
| Mechanism | What it does | Does it help offline? |
|---|---|---|
| manifest.json / webmanifest | Only describes metadata (name, icon, colour, etc.) | ❌ No |
| Adding to the desktop | Creates a shortcut and a window | ❌ No |
| HTTP Cache | May hold resources in memory, but not reliably | ⚠️ Only if you opened it recently and did not clear the cache |
The browser cache may "accidentally" hold on to resources,
and the app may open offline once — but this is not reliable.
You need to add a Service Worker that:
Installs on first launch.
Caches the files you need (index.html, main.js, style.css, images and so on).
On the next launch:
if there is internet — loads fresh files;
if not — takes them from the cache.
A minimal example:
// service-worker.js
self.addEventListener('install', event => { event.waitUntil( caches.open('my-cache-v1').then(cache => { return cache.addAll([ '/', // the home page '/index.html', '/style.css', '/main.js', '/icon.png' ]); }) ); }); self.addEventListener('fetch', event => { event.respondWith( fetch(event.request).catch(() => caches.match(event.request)) ); });
And registering it in the HTML:
After this:
The app will be able to launch even with no network,
showing cached files or an offline page.
| Condition | Will it work offline? |
|---|---|
| Without a Service Worker | ❌ No |
| With a Service Worker and a cache | ✅ Yes |
| The site is opened for the first time and not cached | ❌ No |
| The site has already been visited, but the server is unavailable | ⚠️ Sometimes (if you get lucky with the HTTP cache) |
Would you like me to show an example of a Service Worker that provides offline mode with a custom "You are offline" page,
so that the user sees a nice message instead of a browser error?
Yes, this is a good point — let's go through it in detail.
In manifest.json the start_url field sets the URL from which the installed app will be launched.
"." in a URL is a relative path pointing to the current directory of the manifest file.
That is, the browser will launch the app from the same directory where manifest.json is located.
An example structure:
If manifest.json specifies:
and the manifest.json file is at the root /, then . → /
That is, the app will launch from /index.html (or /, depending on the server).
If the file is at /app/manifest.json, then . → /app/.
Valid. The PWA JSON specification allows relative paths.
The main thing is that resolving it relative to the manifest's location produces a correct URL.
Depending on where the manifest is located, the result may differ.
If manifest.json is at the root → . = /
If manifest.json is in the /app/ subdirectory → . = /app/
It is better to specify the path explicitly relative to the site root for unambiguity:
or
Browser compatibility
Chrome, Edge and Opera interpret "." correctly.
Safari (iOS) also works, but sometimes an absolute path is better for reliability.
| start_url | What it means | Recommendation |
|---|---|---|
| "." | The manifest's current directory | ✅ Works, but the relative path depends on where the file is located |
| "/" | The site root | ✅ Unambiguous and safe |
| "/app/" | The /app/ subdirectory | ✅ Controlled behaviour for a PWA |
Comments