All Posts
ArchitectureOffline-FirstEncryptionIndexedDBService Workers

Offline-First Kiosk Architecture with Encrypted Order Queuing

Inside the encrypted offline order queue powering EyeBrowse retail kiosks — AES-256-GCM, IndexedDB, and service workers, because retail WiFi is never as reliable as the demo environment.

6 min read

The EyeBrowse kiosk sits in retail stores where customers browse and order eyewear. It looks like a tablet app, but it is a Next.js PWA running in a locked-down Chrome kiosk session. The catch: retail WiFi is unreliable. Not "sometimes slow" unreliable — "drops for 20 minutes during peak hours because the router is behind the microwave in the break room" unreliable.

If a customer spends 15 minutes picking out frames, tries them on with AR, enters their prescription, and hits "Place Order" only to see a spinner that never resolves — that is a lost sale and a frustrated retailer. The order needs to go through regardless of connectivity.

The Architecture

The offline order queue has four components:

  1. Encryption layer — AES-256-GCM encrypts orders before they touch storage
  2. IndexedDB store — Encrypted payloads persist across browser restarts
  3. Queue processor — Decrypts and submits orders when connectivity returns
  4. Service worker — Triggers background sync and handles retry logic

Why Encrypt at Rest?

Kiosks are physically accessible. Someone could plug in a USB drive and dump IndexedDB. Orders contain names, prescriptions, and payment references — PII that needs protection even on a device under direct control.

The system uses Web Crypto API's AES-256-GCM with a per-device key:

const secret = getKioskSecret(); // NEXT_PUBLIC_KIOSK_DEVICE_KEY
const hash = await crypto.subtle.digest(
  "SHA-256",
  new TextEncoder().encode(secret)
);
const key = await crypto.subtle.importKey(
  "raw", hash, { name: "AES-GCM" }, false,
  ["encrypt", "decrypt"]
);

Each order gets a fresh 96-bit initialization vector (IV). The encrypted payload is wrapped in an envelope that includes a key ID (first 16 chars of the SHA-256 hash) so the decryption side can verify it is using the correct key before attempting decryption:

interface OfflineEncryptionEnvelope {
  version: 1;
  iv: string;      // base64-encoded 96-bit nonce
  ciphertext: string; // base64-encoded AES-GCM output
  keyId: string;   // sha256-hash-first-16-chars
}

The version field is there for forward compatibility. If the encryption scheme ever needs to evolve (key rotation, parameter changes, or migration to a future AEAD cipher), existing envelopes can be identified and migrated without data loss.

The IndexedDB Schema

Each queued order is a record with three sections: the encrypted payload, human-readable metadata (for admin visibility), and retry state:

type QueuedOrderRecord = {
  id?: number; // auto-increment
  encryptedPayload: OfflineEncryptionEnvelope;
  metadata: {
    clientReferenceId: string;
    practiceId: string;
    paymentProvider: string;
    createdAt: number;
    totalCents: number;
  };
  retryMetadata: {
    attempts: number;
    lastError: string | null;
    lastAttemptAt?: number;
  };
};

The metadata is intentionally not encrypted — it contains no PII, and the admin dashboard needs to show queue status (how many orders pending, total value, last attempt time) without decrypting every record.

Queue Processing

When an order is queued, two things happen simultaneously:

  1. The order is encrypted and written to IndexedDB
  2. A navigator.serviceWorker.ready call registers a background sync event
async function queueOfflineOrder(
  orderRequest: OfflineOrderRequest
): Promise<QueueOfflineOrderResult> {
  const encryptedPayload = await encryptOfflinePayload(orderRequest);
  const db = await openOfflineOrdersDB(indexedDB);
  const tx = db.transaction("orders", "readwrite");
 
  const record = {
    encryptedPayload,
    metadata: {
      clientReferenceId: crypto.randomUUID(),
      practiceId: orderRequest.practiceId,
      paymentProvider: orderRequest.paymentProvider,
      createdAt: Date.now(),
      totalCents: orderRequest.totalCents,
    },
    retryMetadata: { attempts: 0, lastError: null },
  };
 
  const id = await storeAdd(tx.objectStore("orders"), record);
 
  // Fire-and-forget — sync will happen when possible
  requestBackgroundSync().catch(() => {});
 
  return { success: true, recordId: id };
}

The requestBackgroundSync call is fire-and-forget because the service worker might not be available (first load, or after a service worker update). In that case, the next page load checks for pending orders and processes them.

The Service Worker Side

The service worker listens for two events:

  1. Background sync — triggered by the browser when connectivity is restored
  2. Direct message — a PROCESS_QUEUED_ORDERS postMessage from the main thread

For each pending order, the worker:

  1. Reads the encrypted payload from IndexedDB
  2. Decrypts it using the device key
  3. Submits it to the order API
  4. On success: deletes the record
  5. On failure: increments the retry counter and updates lastError

The retry strategy is deliberately simple: attempt immediately on sync, then let the next background sync event trigger another attempt. No exponential backoff in the worker itself — the browser's background sync API already handles timing. If an order fails 5 times, it is flagged for manual review in the admin dashboard rather than retried indefinitely.

The Dev/Prod Key Problem

There is a subtle footgun in this architecture. The kiosk device key comes from NEXT_PUBLIC_KIOSK_DEVICE_KEY, which is baked into the client bundle at build time. But the service worker is a separate JavaScript file with its own scope.

In production, both the main thread and the service worker see the same environment variable — no problem. In development, the service worker might be running a cached version with a different (or missing) key. Orders encrypted by the main thread cannot be decrypted by the service worker.

In development, the main thread and the service worker each derive their own fallback key — crypto.randomUUID() returns a new value on every call, so the two contexts never agree on a key without an explicitly shared source. The defense is a keyId check: the worker compares the envelope's keyId against its own derived ID and, if they do not match, skips the order and logs a warning rather than corrupting data with a failed decryption. (Production runs on a shared NEXT_PUBLIC_KIOSK_DEVICE_KEY, so this mismatch only triggers locally.)

In production, the system throws InsecureKioskSecretError if the device key is missing entirely. Better to fail loudly at deploy time than silently store unencryptable orders.

What This Enables

With the offline queue in place, the kiosk experience is seamless:

  • Customer places order → instant confirmation (order is queued locally)
  • WiFi drops → no visible impact, orders accumulate in IndexedDB
  • WiFi returns → service worker fires, orders drain to the server
  • Admin dashboard → shows pending queue count and total value in real-time

The entire queue typically drains within seconds of connectivity returning. The longest an order has sat in the queue under observation is about 45 minutes during a particularly bad network outage — and it processed correctly on the first retry.

Across the kiosk's end-to-end test suite — multi-week staging runs covering connectivity drops, partial-write failures, and service-worker version mismatches — no order has been lost. That is the bar offline-first work has to clear: not "it works when you test it," but "it works when the network fails in ways nobody anticipated."