How to collect feedback in a progressive web app
Load the widget once in the app shell so it works on every screen, and tag each report with whether the app runs installed or in a browser tab. When the device is offline, keep reports in a small localStorage queue and send them through the REST API when the connection returns, one at a time and at a steady pace.
By Rafael Thayto 路 Published 路 Last updated
Why a PWA needs its own offline path
A progressive web app runs in a browser, so the widget works in it the way it works on any website. The difference is the context. A PWA is often installed on a home screen, opened with no address bar, and used on trains, in warehouses or on weak mobile networks. A report written in those conditions can fail, and a user who sees a failure may never try again.
The plan has two parts. The widget covers the normal case, with a floating button in the app shell. A small queue covers the offline case, storing reports on the device and sending them through the REST API when the connection comes back. The REST API reference lists the body fields, and each saved report returns 201 with its id.
Load the widget once in the app shell
The app shell is the part of the app that stays on screen while content changes, such as the header, the navigation and the layout. Put the widget script in the shell's HTML, not in each route, so it loads once and the button appears on every screen:
<script src="https://escutaproduto.com/widget.js" data-key="pk_your_product_key" data-position="right" defer></script>
Single page apps change routes without a full page load, so the widget stays in memory and keeps its state. Do not reload the script on each navigation. The page address is captured automatically when a report is sent, so the route the user was on is already in the report. The widget reference lists the options, including data-color and data-locale.
Tell the widget who is signed in once the user logs in, so the email field is hidden for them. The identify call is safe to make before the script loads, because the queue form in the widget's documentation handles that case.
Tell installed and browser users apart
Installed users and browser users behave differently. An installed app usually has a stable version and a regular audience, while a browser visit is often a first look. A simple check tells you which one a report came from:
function displayMode() {
const standalone =
window.matchMedia("(display-mode: standalone)").matches ||
window.navigator.standalone === true;
return standalone ? "installed" : "browser";
}
window.addEventListener("load", () => {
window.EscutaProduto.setMetadata({
displayMode: displayMode(),
appVersion: "3.1.0",
});
});
The navigator.standalone property covers older iOS versions, which report installed apps through a different flag. Keep the app version as a constant that your build fills in from the package file, so each report names the release it came from.
Keep reports on the device when the network is down
The widget sends its form only when the request can reach the server. When the device is offline, use your own form and a queue. Store the queue in localStorage, which is simple and holds small amounts of text well. Wrap every read and write in try and catch, because some browsers block storage in private windows or when site data is cleared:
const QUEUE_KEY = "feedback-queue";
function readQueue() {
try {
return JSON.parse(localStorage.getItem(QUEUE_KEY) || "[]");
} catch (error) {
return [];
}
}
function writeQueue(items) {
try {
localStorage.setItem(QUEUE_KEY, JSON.stringify(items.slice(-50)));
} catch (error) {
// Storage is blocked or full. The report is lost, so tell the user.
}
}
function queueFeedback(message, kind) {
const items = readQueue();
items.push({
key: "pk_your_product_key",
kind: kind,
message: message,
pageUrl: window.location.href,
metadata: { displayMode: displayMode(), queuedAt: new Date().toISOString() },
});
writeQueue(items);
}
The cap of 50 items keeps the queue small. If the device stays offline for a long time, the oldest reports drop off first, which is an acceptable trade-off for a feedback form. For large volumes, store the queue in IndexedDB instead.
Send the queue when the connection returns
Flush the queue on startup and whenever the browser reports that the network is back. Send the items one at a time, and wait between them. The API allows 10 requests per minute per IP address and product, and a PWA's requests come from each user's own device, so a steady pace of one request every six seconds stays under the limit:
const API_URL = "https://escutaproduto.com/api/v1/feedback";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function flushQueue() {
const snapshot = readQueue();
const remaining = [];
for (const body of snapshot) {
try {
const response = await fetch(API_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (response.status === 429 || response.status >= 500) remaining.push(body);
} catch (error) {
remaining.push(body);
}
await sleep(6000);
}
const added = readQueue().slice(snapshot.length);
writeQueue(remaining.concat(added));
}
window.addEventListener("online", flushQueue);
flushQueue();
The function removes a report only when the API accepts it or refuses it for a reason that retrying cannot fix. A 400 or 404 is dropped, because the same body will fail again. A 429 or server error stays in the queue. Reports added while the flush was running are kept, because the function reads the queue again before it writes.
The sleep between items makes a long queue slow, but it protects the limit. A queue of twenty reports takes about two minutes to clear, which is fine for feedback. Do not try to speed it up by sending everything at once, because the extra requests return 429 and the reports wait anyway.
What about the service worker and the widget script?
The service worker decides what the app can fetch offline. Two rules keep feedback working. First, never answer a POST request to the API from the cache, because the request has a body and must reach the server. Second, be careful with caching the widget script itself. A cache-first strategy can keep an old copy of widget.js in place after an update, so a network-first strategy is safer for that one file.
The Background Sync API can send queued work later, but it is not available in every browser, especially on iOS. Treat it as a bonus. The foreground flush on startup and on the online event is the reliable part, so the queue always has a way to drain.
Where Escuta Produto sits in your PWA
Create a product for the app and copy its public key into the shell and the queue code. Load the app once, send a report from the widget and check that the display mode and version appear in the inbox. Then turn off the network, write a report with your own form, turn the network on and confirm that the queued report arrives. Connect the Slack or Discord webhook so the team hears about new reports quickly.
The notifications guide explains the webhook, and the triage guide covers sorting the results. For the general approach to where entry points belong, the widget best practices guide is a good companion, and the mobile app article covers the same REST API pattern in native apps.
Frequently asked questions
Does the feedback widget work when a PWA is offline?
The widget needs a connection to send, so it cannot deliver a report while offline. Use your own form that writes to a local queue, then send the queue through the REST API when the browser reports that the network is back.
How do I know whether a report came from the installed app or the browser?
Check the display-mode media query for standalone, and the iOS standalone flag as a fallback. Send the result as metadata with setMetadata or in the queued body, so you can filter by it in the export.
Where should a PWA store unsent feedback?
Store it in localStorage for small queues, wrapping every read and write in try and catch because some browsers block storage. Use IndexedDB when the queue grows large. Remove each item only after the API accepts it or refuses it permanently.
Related
- How to collect feedback in a SaaS productCollect in-app feedback from SaaS customers: load the widget once, identify users after login, add plan metadata and place entry points where work happens.
- How to collect feedback in a mobile appCollect feedback from iOS, Android or React Native with one REST API request, app version metadata and a hosted form link in your store listing.
- How to collect feedback for a Chrome extensionCollect Chrome extension feedback with a REST API call from the popup, a hosted form as the uninstall URL and the extension version on every report.
- How to collect feedback for a WordPress pluginCollect feedback for a WordPress plugin with a settings page link to a hosted form, server-side API posts with site details, and no remote script in wp-admin.