diff --git a/index.html b/index.html
index 795f24e..21efab9 100644
--- a/index.html
+++ b/index.html
@@ -48,6 +48,11 @@
diff --git a/sw.js b/sw.js
new file mode 100644
index 0000000..551584b
--- /dev/null
+++ b/sw.js
@@ -0,0 +1,96 @@
+// The Nexus — Service Worker
+// Cache-first for assets, network-first for API calls
+
+const CACHE_NAME = 'nexus-v1';
+const ASSET_CACHE = 'nexus-assets-v1';
+
+const CORE_ASSETS = [
+ '/',
+ '/index.html',
+ '/app.js',
+ '/style.css',
+ '/manifest.json',
+ '/ws-client.js',
+ 'https://unpkg.com/three@0.183.0/build/three.module.js',
+ 'https://unpkg.com/three@0.183.0/examples/jsm/controls/OrbitControls.js',
+ 'https://unpkg.com/three@0.183.0/examples/jsm/postprocessing/EffectComposer.js',
+ 'https://unpkg.com/three@0.183.0/examples/jsm/postprocessing/RenderPass.js',
+ 'https://unpkg.com/three@0.183.0/examples/jsm/postprocessing/UnrealBloomPass.js',
+ 'https://unpkg.com/three@0.183.0/examples/jsm/postprocessing/ShaderPass.js',
+ 'https://unpkg.com/three@0.183.0/examples/jsm/shaders/CopyShader.js',
+ 'https://unpkg.com/three@0.183.0/examples/jsm/shaders/LuminosityHighPassShader.js',
+];
+
+// Install: precache core assets
+self.addEventListener('install', (event) => {
+ event.waitUntil(
+ caches.open(ASSET_CACHE).then((cache) => cache.addAll(CORE_ASSETS))
+ .then(() => self.skipWaiting())
+ );
+});
+
+// Activate: clean up old caches
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ caches.keys().then((keys) =>
+ Promise.all(
+ keys
+ .filter((key) => key !== CACHE_NAME && key !== ASSET_CACHE)
+ .map((key) => caches.delete(key))
+ )
+ ).then(() => self.clients.claim())
+ );
+});
+
+self.addEventListener('fetch', (event) => {
+ const { request } = event;
+ const url = new URL(request.url);
+
+ // Network-first for API calls (Gitea / WebSocket upgrades / portals.json live data)
+ if (
+ url.pathname.startsWith('/api/') ||
+ url.hostname.includes('143.198.27.163') ||
+ request.headers.get('Upgrade') === 'websocket'
+ ) {
+ event.respondWith(networkFirst(request));
+ return;
+ }
+
+ // Cache-first for everything else (local assets + CDN)
+ event.respondWith(cacheFirst(request));
+});
+
+async function cacheFirst(request) {
+ const cached = await caches.match(request);
+ if (cached) return cached;
+
+ try {
+ const response = await fetch(request);
+ if (response.ok) {
+ const cache = await caches.open(ASSET_CACHE);
+ cache.put(request, response.clone());
+ }
+ return response;
+ } catch {
+ // Offline and not cached — return a minimal fallback for navigation
+ if (request.mode === 'navigate') {
+ const fallback = await caches.match('/index.html');
+ if (fallback) return fallback;
+ }
+ return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
+ }
+}
+
+async function networkFirst(request) {
+ try {
+ const response = await fetch(request);
+ if (response.ok) {
+ const cache = await caches.open(CACHE_NAME);
+ cache.put(request, response.clone());
+ }
+ return response;
+ } catch {
+ const cached = await caches.match(request);
+ return cached || new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
+ }
+}