How Spinbob Shipped Its Mobile Casino as a PWA Instead of a Store App
Spinbob is an online casino brand that wanted a proper mobile app: an icon on the home screen, a full-screen experience without browser chrome, fast cold starts and push notifications. What it did not want was two native codebases and an App Store and Google Play submission cycle sitting between every change and its players.
The team shipped a Progressive Web App instead. This post walks through why that trade-off made sense for a real-money casino specifically, what the store route would actually have required, and how the PWA is built: manifest, service worker caching strategy, install flow on Android and iOS, and web push.
The store route, and why it is heavier for gambling
It is a common misconception that casino apps are simply banned from the app stores. They are not. Both stores accept real-money gambling apps, but they treat them as a regulated category with far more process than an ordinary app.
On Google Play, the developer has to complete a separate application process and hold a licence from the relevant gambling authority for every country and game type the app offers. The app must block access from regions its licence does not cover, prevent under-age use, carry an Adults Only rating, be free to download, and must not use Google Play billing for wagers. It also has to show responsible gambling information both in the app and on the store listing. (Google Play Console Help)
Apple's App Review Guidelines take a similar line in section 5.3: real-money gaming apps need the appropriate licences, must be geo-restricted to the jurisdictions where they are licensed, must be free on the App Store, and are expected to be submitted by the licensed operator itself. Section 4.2 adds a separate hurdle: an app that is essentially a website in a native shell, or one generated from a template service on the operator's behalf, is a frequent rejection reason.
None of that is unreasonable. For an operator running several brands across several jurisdictions, though, it compounds:
One listing, many licences. Each market adds paperwork to the store relationship, on top of the regulator relationship the operator already has.
Review sits on the release path. Every native release, including urgent fixes, waits for store review. Casino front-ends change constantly: promotions, game lobbies, payment methods, regulatory copy.
Two native codebases (or a cross-platform framework) to maintain, staff and keep at parity with the web product, which already exists and already works.
The wrapper shortcut does not work. Wrapping the existing website in a WebView to save effort is precisely the kind of app that review tends to reject.
Spinbob's mobile product (official site) was already a responsive web app. The question became: how much of the "real app" experience can the web platform deliver on its own?
What a PWA actually is
A Progressive Web App is not a framework. It is an ordinary website that meets a few conditions so the browser will treat it as installable:
It is served over HTTPS.
It has a web app manifest, a JSON file describing the app's name, icons, start URL and display mode.
It registers a service worker, a script that runs separately from the page, intercepts network requests and can handle push events.
Once those are in place, the browser can install the site to the home screen. Launched from there, it opens in its own window without the address bar, appears in the app switcher, and can receive push notifications. Updates ship the moment new files are deployed; there is nothing to submit.
The manifest
The manifest is what turns "a website" into "an installable app". Spinbob's is close to this:
json
{
"name": "Spinbob Casino",
"short_name": "Spinbob",
"id": "/?source=pwa",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#0b0b14",
"theme_color": "#0b0b14",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"shortcuts": [
{ "name": "Lobby", "url": "/lobby?source=pwa" },
{ "name": "Cashier", "url": "/cashier?source=pwa" }
]
}A few details matter more than they look:
idgives the installed app a stable identity, so changingstart_urllater does not create a "new" app on users' devices.?source=pwaon the start URL lets analytics separate installed sessions from browser sessions. That is the only reliable way to measure whether the PWA is pulling its weight.The maskable icon prevents Android from shrinking the logo inside a white circle.
iOS still reads some legacy tags, so the HTML head keeps
apple-touch-iconandapple-mobile-web-app-status-bar-stylealongside the manifest link.
The service worker: cache the shell, never the money
The service worker is where most of the engineering judgment goes, and for a casino the rules are stricter than for a content site. A stale article is harmless. A stale balance, bonus status or bet result is a support ticket, and possibly a compliance problem.
Spinbob splits requests into three classes:
App shell and static assets (JS bundles, CSS, fonts, icons, lobby artwork): cache-first, versioned by build hash.
Lobby and catalogue data (game lists, promo banners): stale-while-revalidate, so the lobby paints instantly and refreshes in the background.
Anything account- or money-related (balance, wallet, cashier, bets, KYC, session): network-only. Never cached, never served offline.
// sw.js
const VERSION = 'v2026.10.07';
const SHELL_CACHE = `shell-${VERSION}`;
const DATA_CACHE = 'lobby-data';
const SHELL = ['/', '/offline.html', '/app.js', '/app.css', '/icons/icon-192.png'];
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(SHELL_CACHE).then((c) => c.addAll(SHELL)));
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((k) => k.startsWith('shell-') && k !== SHELL_CACHE)
.map((k) => caches.delete(k))
)
)
);
self.clients.claim();
});
const NETWORK_ONLY = [/^\/api\/(wallet|cashier|balance|bets|kyc|session|auth)/];
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Only handle our own origin; game providers' iframes go straight to the network.
if (url.origin !== self.location.origin || event.request.method !== 'GET') return;
if (NETWORK_ONLY.some((re) => re.test(url.pathname))) return; // browser default: network
if (url.pathname.startsWith('/api/lobby')) {
event.respondWith(staleWhileRevalidate(event.request));
return;
}
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() => caches.match('/offline.html'))
);
return;
}
event.respondWith(
caches.match(event.request).then((hit) => hit || fetch(event.request))
);
});
async function staleWhileRevalidate(request) {
const cache = await caches.open(DATA_CACHE);
const cached = await cache.match(request);
const fresh = fetch(request).then((res) => {
if (res.ok) cache.put(request, res.clone());
return res;
});
return cached || fresh;
}Three design choices are worth calling out.
Navigations go network-first. Pages render server-side with session and geo context, so serving a cached HTML page could show the wrong market's content or a logged-out state. If the network is down, the user gets an honest offline page, not a lobby they cannot play from.
Game clients are left alone. Slot and live-dealer games are loaded from providers' own origins inside iframes. The service worker only handles same-origin requests, so it never interferes with provider streaming, RNG calls or their own caching.
Cache is a performance layer, not a source of truth. Browsers can evict storage under pressure, and iOS in particular is conservative. Everything in the cache can disappear without breaking anything except speed.
Installation: two very different platforms
On Android (Chrome and most Chromium browsers), an installable PWA fires a beforeinstallprompt event. Spinbob intercepts it and shows its own install button at a sensible moment, after login rather than on first page load:
let deferredPrompt = null;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
document.querySelector('#install-btn').hidden = false;
});
document.querySelector('#install-btn').addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
await deferredPrompt.userChoice;
deferredPrompt = null;
});iOS Safari does not fire beforeinstallprompt. Installation is manual: Share, then Add to Home Screen. So the app detects iOS in a browser tab and shows a short illustrated hint instead:
const isStandalone =
window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true;
const isIOS = /iphone|ipad|ipod/i.test(navigator.userAgent);
if (isIOS && !isStandalone) showAddToHomeScreenHint();The same display-mode check drives analytics and UI: inside the installed app the "Get the app" banner disappears, and the layout accounts for the absence of the browser toolbar (using env(safe-area-inset-*) and dynamic viewport units like 100dvh instead of 100vh, which behaves badly with Safari's collapsing toolbar).
Push notifications
Web push uses the Push API and the service worker. The server stores a subscription per device and sends messages through the browser vendor's push service using VAPID keys. On the client:
async function enablePush() {
const reg = await navigator.serviceWorker.ready;
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: VAPID_PUBLIC_KEY
});
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sub)
});
}
// Bound to an explicit button, never called on page load
document.querySelector('#enable-push').addEventListener('click', enablePush);And in the service worker:
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icons/icon-192.png',
data: { url: data.url || '/' }
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(clients.openWindow(event.notification.data.url));
});The iOS caveat is significant: Safari supports web push since iOS 16.4, but only for web apps added to the home screen, and the permission request must come from a user gesture such as a tap (Pushpad). An iPhone user browsing in a Safari tab cannot be subscribed at all. That makes the iOS install hint doubly important: it is the gateway to push.
Notification content follows the same marketing consent and responsible gambling rules as email and SMS. Push being technically cheap does not change what an operator is allowed to send, or to whom.
What the PWA does not change
It is worth being explicit, because "we avoided the app stores" can be misread as "we avoided the rules". It did not, and it cannot. A PWA removes the store as a gatekeeper; it does not remove the regulator.
Everything the stores ask of gambling apps is something a licensed operator has to do anyway, and in a PWA it simply lives server-side:
Geo-restriction is enforced at the edge and the API on every request, not in the client, which can be spoofed.
Age verification and KYC run through the same flows as on desktop.
Licence-specific content, such as terms, bonus rules and safer gambling tools, is resolved from the player's verified jurisdiction.
Responsible gambling tools (deposit limits, reality checks, time-outs, self-exclusion) are in the PWA exactly as on the website, because they are the website.
There is a practical upside here: one codebase means one implementation of these controls, rather than three that must be kept in sync.
Limitations Spinbob accepted
The PWA route has real costs, and they should be weighed honestly:
No store discoverability. Nobody finds the app by searching the App Store. Acquisition comes from the website, search and marketing channels, and the install prompt has to earn its place.
iOS friction. Manual installation and home-screen-only push mean that iOS install rates will trail Android.
Weaker device integration. Background sync and several hardware APIs are unavailable on iOS. Biometric login works through WebAuthn passkeys, but deeper OS hooks such as widgets are out of reach.
Platform policy risk cuts both ways. In early 2024 Apple announced it would drop home screen web apps in the EU with iOS 17.4, then reversed the decision weeks later (TechCrunch). Building on the web avoids store review, but not every platform decision.
For a product whose core is a lobby, a wallet and web-based game clients, those were acceptable costs. A product that depended on store visibility, rich background work or hardware sensors would land differently.
Takeaways for other teams
Start from the release cadence. If your front-end changes weekly and review delays hurt, a PWA's instant updates are the headline benefit.
Classify every request before writing a caching rule. Decide what may be stale, what must be fresh and what must never touch the cache.
Design two install journeys. Android gets a prompt you control; iOS needs education.
Treat push as an installed-app feature on iOS and plan the funnel accordingly.
Keep compliance on the server. The client is a presentation layer. Rules that matter must hold even if the client is modified.
Frequently asked questions
Are casino apps banned from the App Store and Google Play? No. Both stores accept real-money gambling apps from licensed operators, but they require licences for each jurisdiction, geo-restriction, age controls and a stricter review process than ordinary apps.
Does a PWA let a casino avoid gambling regulation? No. A PWA only removes the app store as a distribution gatekeeper. Licensing, geo-restriction, age verification, KYC and responsible gambling obligations still apply and are enforced on the server.
Can a PWA send push notifications on iPhone? Yes, from iOS 16.4, but only after the user has added the web app to the home screen, and the permission request must be triggered by a user action such as tapping a button.
What should a casino PWA never cache? Balances, wallet and cashier data, bet results, session and authentication responses, and verification flows. These should always come from the network so players never see stale financial information.
Why not just wrap the website in a native app? A WebView wrapper still goes through store review, including gambling-specific approval, and Apple's guidelines frequently reject apps that are little more than a website in a native shell.
Spinbob is a real-money gambling brand for adults aged 18 and over, where permitted by law. This article is a technical case study and contains no affiliate links. If gambling is affecting you or someone you know, free and confidential support is available from BeGambleAware.


