Running PWAs Locally Without a Server: Offline-First Principles and Use Cases

The modern web assumes connectivity. Every framework, every tutorial, every deployment guide starts with npm run dev and a localhost server. But what happens when you need your application to work in a disconnected environment—on a plane, in a rural clinic, inside a secure facility, or on a device that will never see the internet?

This isn't theoretical. I've shipped applications to field researchers in the Amazon, medical technicians in sub-Saharan Africa, and soldiers in active conflict zones. In each case, the assumption of reliable connectivity was not just wrong—it was dangerous.

What "Serverless Local" Actually Means

Let's clarify terminology. When we say "without a server," we mean:

This is distinct from "static site generation" (still needs a server to serve files) or "serverless computing" (still needs the cloud). We're talking about truly autonomous applications.

The Technical Foundation: Service Workers + File Protocol

Progressive Web Apps (PWAs) were designed for offline capability, but most implementations still assume a web server handles the initial load. To run from file:// URLs, we need to address several constraints:

Constraint 1: Module Loading

ES modules using import fail on file:// due to CORS restrictions. The solution is to bundle everything into a single file, or use a lightweight module shim:

// offline-loader.js
// Lightweight module system for file:// protocol execution
const OfflineModule = {
  cache: new Map(),
  
  define(name, factory) {
    if (this.cache.has(name)) return;
    
    const exports = {};
    const module = { exports };
    factory(exports, module);
    this.cache.set(name, module.exports || exports);
  },
  
  require(name) {
    if (!this.cache.has(name)) {
      throw new Error(`Module not found: ${name}`);
    }
    return this.cache.get(name);
  }
};

// Self-contained app bundle
define('app', (exports, module) => {
  const React = {
    createElement: (tag, props, ...children) => ({ tag, props, children }),
    render: (vnode, container) => {
      // Simplified virtual DOM to real DOM
      const el = document.createElement(vnode.tag);
      Object.assign(el, vnode.props || {});
      vnode.children.forEach(child => {
        el.appendChild(typeof child === 'string' 
          ? document.createTextNode(child) 
          : React.render(child, document.createElement('div'))
        );
      });
      container.appendChild(el);
      return el;
    }
  };
  
  module.exports = { React };
});

// Entry point
window.onload = () => {
  const { React } = require('app');
  const app = React.createElement('div', { id: 'root' },
    React.createElement('h1', null, 'Offline PWA'),
    React.createElement('p', null, 'Running without a server')
  );
  React.render(app, document.body);
};

Constraint 2: Data Persistence

Without a backend, client-side storage becomes critical. Modern browsers offer several options:

// storage-manager.js
class OfflineStorage {
  constructor(dbName = 'offline-app', version = 1) {
    this.dbName = dbName;
    this.version = version;
    this.db = null;
  }
  
  async init(stores = ['data', 'files', 'metadata']) {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, this.version);
      
      request.onerror = () => reject(request.error);
      request.onsuccess = () => {
        this.db = request.result;
        resolve(this);
      };
      
      request.onupgradeneeded = (event) => {
        const db = event.target.result;
        stores.forEach(store => {
          if (!db.objectStoreNames.contains(store)) {
            db.createObjectStore(store, { keyPath: 'id', autoIncrement: true });
          }
        });
      };
    });
  }
  
  async save(store, data) {
    return new Promise((resolve, reject) => {
      const tx = this.db.transaction(store, 'readwrite');
      const objStore = tx.objectStore(store);
      const request = objStore.put(data);
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }
  
  async load(store, id) {
    return new Promise((resolve, reject) => {
      const tx = this.db.transaction(store, 'readonly');
      const objStore = tx.objectStore(store);
      const request = id ? objStore.get(id) : objStore.getAll();
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }
  
  // Export entire database as JSON for backup/transfer
  async export() {
    const stores = Array.from(this.db.objectStoreNames);
    const data = {};
    
    for (const store of stores) {
      data[store] = await this.load(store);
    }
    
    return JSON.stringify(data, null, 2);
  }
  
  // Import from JSON backup
  async import(jsonData) {
    const data = JSON.parse(jsonData);
    
    for (const [store, records] of Object.entries(data)) {
      for (const record of records) {
        await this.save(store, record);
      }
    }
  }
}

Architecture Pattern: The Self-Contained App Package

For maximum portability, structure your application as a single deliverable:

my-offline-app/
├── index.html           # Single entry point
├── app.js              # Bundled application logic
├── data/               # Pre-loaded reference data
│   ├── dictionary.json
│   ├── maps/
│   └── reference/
├── assets/             # Static resources
│   ├── images/
│   ├── fonts/
│   └── styles.css
└── manifest.json       # PWA manifest (even offline)

The Build Pipeline: Vite for Static-First

Vite's build system is remarkably well-suited for offline-first applications. The key is configuring it for relative paths and single-file output:

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { viteSingleFile } from 'vite-plugin-singlefile';

export default defineConfig({
  plugins: [react(), viteSingleFile()],
  base: './',  // Critical: relative paths for file://
  build: {
    target: 'esnext',
    assetsDir: '.',  // Inline everything
    rollupOptions: {
      output: {
        manualChunks: undefined,  // Single chunk
        inlineDynamicImports: true
      }
    }
  }
});

Real-World Use Case: Field Research Database

Consider a biodiversity research team collecting specimen data in remote Madagascar. Their workflow:

  1. Pre-departure: Install app from USB stick (single HTML file)
  2. In the field: Collect GPS-tagged observations, photos stored as Base64 in IndexedDB
  3. Data validation: Offline-first forms with local species reference data
  4. Sync upon return: Export JSON, email to central server, or physically transfer USB
// field-research-app.js
class FieldResearchApp {
  constructor() {
    this.storage = new OfflineStorage('madagascar-bio', 1);
    this.observations = [];
  }
  
  async init() {
    await this.storage.init(['observations', 'species_ref', 'photos']);
    
    // Load reference data from embedded JSON
    const speciesData = await fetch('./data/madagascar_species.json')
      .then(r => r.json())
      .catch(() => this.getEmbeddedSpeciesData());
    
    for (const species of speciesData) {
      await this.storage.save('species_ref', species);
    }
  }
  
  async recordObservation(gps, photoBlob, notes) {
    // Convert photo to Base64 for IndexedDB storage
    const photoBase64 = await this.blobToBase64(photoBlob);
    
    const observation = {
      id: crypto.randomUUID(),
      timestamp: new Date().toISOString(),
      latitude: gps.latitude,
      longitude: gps.longitude,
      photo: photoBase64,
      notes,
      synced: false
    };
    
    await this.storage.save('observations', observation);
    this.observations.push(observation);
    
    return observation.id;
  }
  
  async exportForSync() {
    const unsynced = this.observations.filter(o => !o.synced);
    const exportData = {
      export_date: new Date().toISOString(),
      device_id: this.getDeviceId(),
      observations: unsynced
    };
    
    // Create downloadable file
    const blob = new Blob([JSON.stringify(exportData)], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    
    const a = document.createElement('a');
    a.href = url;
    a.download = `madagascar-export-${Date.now()}.json`;
    a.click();
    
    URL.revokeObjectURL(url);
  }
  
  blobToBase64(blob) {
    return new Promise((resolve) => {
      const reader = new FileReader();
      reader.onloadend = () => resolve(reader.result);
      reader.readAsDataURL(blob);
    });
  }
  
  getEmbeddedSpeciesData() {
    // Fallback: inline minimal dataset
    return [
      { id: 'LEM-CATTA', common: 'Ring-tailed Lemur', scientific: 'Lemur catta' },
      { id: 'IND-INDRI', common: 'Indri', scientific: 'Indri indri' }
    ];
  }
  
  getDeviceId() {
    let id = localStorage.getItem('device_id');
    if (!id) {
      id = crypto.randomUUID();
      localStorage.setItem('device_id', id);
    }
    return id;
  }
}

Packaging for Distribution

The final challenge is getting your application onto devices that may never connect to the internet. Options include:

Method Best For Tradeoffs
USB / SD Card One-to-many distribution Physical media required
Local WiFi (no internet) Team synchronization Requires temporary hotspot
Bluetooth / NFC Phone-to-phone transfer Slow for large assets
Pre-installed ROM Embedded/industrial systems Requires hardware access

Security Considerations

Offline applications face unique security challenges:

// simple-crypto.js
// Lightweight encryption for offline data
class OfflineCrypto {
  constructor() {
    this.encoder = new TextEncoder();
    this.decoder = new TextDecoder();
  }
  
  async deriveKey(password, salt) {
    const keyMaterial = await crypto.subtle.importKey(
      'raw', this.encoder.encode(password), 'PBKDF2', false, ['deriveKey']
    );
    
    return crypto.subtle.deriveKey(
      { name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' },
      keyMaterial,
      { name: 'AES-GCM', length: 256 },
      false,
      ['encrypt', 'decrypt']
    );
  }
  
  async encrypt(data, password) {
    const salt = crypto.getRandomValues(new Uint8Array(16));
    const iv = crypto.getRandomValues(new Uint8Array(12));
    const key = await this.deriveKey(password, salt);
    
    const encrypted = await crypto.subtle.encrypt(
      { name: 'AES-GCM', iv },
      key,
      this.encoder.encode(JSON.stringify(data))
    );
    
    // Package: salt + iv + ciphertext
    const result = new Uint8Array(salt.length + iv.length + encrypted.byteLength);
    result.set(salt, 0);
    result.set(iv, salt.length);
    result.set(new Uint8Array(encrypted), salt.length + iv.length);
    
    return btoa(String.fromCharCode(...result));
  }
  
  async decrypt(base64Ciphertext, password) {
    const data = Uint8Array.from(atob(base64Ciphertext), c => c.charCodeAt(0));
    
    const salt = data.slice(0, 16);
    const iv = data.slice(16, 28);
    const ciphertext = data.slice(28);
    
    const key = await this.deriveKey(password, salt);
    
    const decrypted = await crypto.subtle.decrypt(
      { name: 'AES-GCM', iv },
      key,
      ciphertext
    );
    
    return JSON.parse(this.decoder.decode(decrypted));
  }
}

The Bottom Line

Building applications that work without servers isn't a niche skill—it's becoming essential as computing moves to edge devices, offline scenarios, and privacy-conscious users. The web platform has matured to support genuinely autonomous applications.

The pattern is clear: bundle everything, store locally, encrypt sensitive data, and design for eventual synchronization rather than constant connectivity. Your users will thank you when their application keeps working long after the WiFi drops.