logic from runtime constraints using a platform abstraction layer. This pattern allows a single codebase to target both environments while isolating platform-specific behavior behind consistent interfaces.
Start by abstracting the capabilities that diverge between runtimes: file system access, hardware communication, and update handling.
// src/platform/types.ts
export interface PlatformCapabilities {
readLocalFile(path: string): Promise<Uint8Array>;
writeLocalFile(path: string, data: Uint8Array): Promise<void>;
checkForUpdates(): Promise<{ available: boolean; version: string }>;
installUpdate(): Promise<void>;
connectToPeripheral(deviceId: string): Promise<ReadableStream>;
}
export type PlatformType = 'electron' | 'pwa';
This interface enforces a contract. Business logic never imports fs, ipcMain, or navigator.serviceWorker directly. It depends on PlatformCapabilities, which are implemented differently per target.
Step 2: Implement the PWA Adapter
The PWA implementation relies on the Cache API, Origin Private File System (OPFS), and service worker messaging. Hardware access is gated behind feature detection.
// src/platform/pwa-adapter.ts
import { PlatformCapabilities } from './types';
export class PWAAdapter implements PlatformCapabilities {
async readLocalFile(path: string): Promise<Uint8Array> {
const cache = await caches.open('app-v1');
const response = await cache.match(path);
if (!response) throw new Error('File not cached');
return new Uint8Array(await response.arrayBuffer());
}
async writeLocalFile(path: string, data: Uint8Array): Promise<void> {
const cache = await caches.open('app-v1');
const response = new Response(data);
await cache.put(path, response);
}
async checkForUpdates(): Promise<{ available: boolean; version: string }> {
const registration = await navigator.serviceWorker.ready;
const update = await registration.update();
return { available: update.updated, version: process.env.APP_VERSION || 'unknown' };
}
async installUpdate(): Promise<void> {
const registration = await navigator.serviceWorker.ready;
if (registration.waiting) {
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
}
async connectToPeripheral(deviceId: string): Promise<ReadableStream> {
if (!('hid' in navigator)) {
throw new Error('WebHID not supported in this browser');
}
const devices = await (navigator as any).hid.requestDevice({ filters: [] });
const device = devices.find(d => d.productId.toString() === deviceId);
if (!device) throw new Error('Peripheral not found');
await device.open();
return new ReadableStream({
start(controller) {
device.oninputreport = (event: any) => controller.enqueue(event.data);
}
});
}
}
Architecture Rationale: OPFS and Cache API are used instead of direct disk access because PWAs cannot bypass browser sandboxing. The SKIP_WAITING pattern ensures users receive updates without manual refreshes. Hardware access is explicitly gated; attempting to call WebHID on Safari will throw, preventing silent failures.
Step 3: Implement the Electron Adapter
The Electron implementation leverages Node.js for filesystem operations, electron-updater for distribution, and ipcMain for secure communication.
// src/platform/electron-adapter.ts
import { PlatformCapabilities } from './types';
import { autoUpdater } from 'electron-updater';
import { readFileSync, writeFileSync } from 'fs';
import { serialport } from 'serialport';
export class ElectronAdapter implements PlatformCapabilities {
async readLocalFile(path: string): Promise<Uint8Array> {
return new Uint8Array(readFileSync(path));
}
async writeLocalFile(path: string, data: Uint8Array): Promise<void> {
writeFileSync(path, Buffer.from(data));
}
async checkForUpdates(): Promise<{ available: boolean; version: string }> {
const info = await autoUpdater.checkForUpdates();
return {
available: info.updateInfo.version !== process.env.APP_VERSION,
version: info.updateInfo.version
};
}
async installUpdate(): Promise<void> {
autoUpdater.downloadUpdate();
autoUpdater.on('update-downloaded', () => autoUpdater.quitAndInstall());
}
async connectToPeripheral(deviceId: string): Promise<ReadableStream> {
const port = new serialport({ path: deviceId, baudRate: 9600 });
return new ReadableStream({
start(controller) {
port.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));
}
});
}
}
Architecture Rationale: electron-updater handles differential downloads, signature verification, and rollback logic. Node's synchronous fs methods are used here for simplicity, but production code should use async variants or worker threads to avoid blocking the main process. Serial communication uses serialport instead of Web Serial, guaranteeing cross-OS hardware access without browser restrictions.
Step 4: Wire the Build Pipeline
The build configuration must compile the correct adapter based on the target environment. Vite handles the PWA build, while electron-builder packages the desktop binary.
// vite.config.ts
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
runtimeCaching: [{
urlPattern: /^https:\/\/api\./,
handler: 'NetworkFirst',
options: { cacheName: 'api-cache', expiration: { maxEntries: 50, maxAgeSeconds: 86400 } }
}]
}
})
],
resolve: {
alias: {
'@platform': process.env.TARGET === 'electron'
? './src/platform/electron-adapter'
: './src/platform/pwa-adapter'
}
}
});
Architecture Rationale: Environment-driven aliasing ensures the correct adapter is bundled without runtime conditionals. Workbox configuration enforces a NetworkFirst strategy for API calls, preventing stale data while caching assets for offline resilience. The PWA plugin handles manifest generation, service worker registration, and cache versioning automatically.
Pitfall Guide
1. The ABI Rebuild Blind Spot
Explanation: Electron major releases frequently update the Node ABI. Native modules compiled against an older ABI will crash on launch. Teams that skip CI rebuilds ship broken binaries.
Fix: Integrate electron-rebuild into your CI pipeline. Pin electron and electron-builder versions in package.json. Run npx electron-rebuild before every packaging step. Validate native module compatibility in a clean Docker container.
2. iOS Storage Eviction Assumption
Explanation: WebKit evicts site storage after ~7 days of inactivity. Assuming persistent offline caches leads to broken workflows when users return after a weekend.
Fix: Design for graceful degradation. Never store critical application state exclusively in the service worker cache. Implement a cold-start recovery flow that re-fetches essential data. Use IndexedDB for structured data, but expect periodic eviction on iOS.
3. Service Worker Stale Cache Lock
Explanation: Users remain on an old version when the service worker downloads an update but waits for tab closure to activate. This causes version drift and support tickets.
Fix: Implement skipWaiting() and clients.claim() in the service worker. Expose a UI prompt when registration.waiting exists. Use versioned cache keys (app-v2) to prevent cross-version data corruption.
4. Unscoped BrowserWindow Memory Leaks
Explanation: Each BrowserWindow spawns a renderer process. Failing to destroy windows or detach event listeners causes RAM to climb indefinitely.
Fix: Explicitly call window.destroy() on close. Remove all ipcRenderer listeners in beforeunload. Use webContents memory profiling in CI. Limit concurrent windows and reuse instances where possible.
5. Cross-Browser Hardware API Optimism
Explanation: WebHID, WebUSB, and File System Access API are Chromium-only. Safari and Firefox only support OPFS. Assuming parity breaks functionality on non-Chromium browsers.
Fix: Implement strict feature detection before calling hardware APIs. Provide a fallback path (e.g., manual file upload, Electron binary, or native companion app). Document browser support clearly in your README.
6. Code Signing Neglect
Explanation: Unsigned binaries trigger OS security warnings, block auto-updates, and fail enterprise deployment policies. macOS notarization and Windows SmartScreen require valid certificates.
Fix: Acquire code signing certificates early. Configure electron-builder with mac and win signing options. Automate notarization via CI secrets. Test updates on a clean machine before release.
7. Push Subscription Fragility on iOS
Explanation: iOS PWA push subscriptions expire after ~2 weeks of inactivity. Relying on push for critical notifications causes silent delivery failures.
Fix: Treat iOS push as best-effort. Implement fallback channels (email, in-app polling, webhook retries). Never gate core product features on push delivery. Monitor subscription health via analytics.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Enterprise internal tool with strict compliance | Electron | Deterministic runtime, offline-first, full filesystem access, enterprise signing | High initial setup, moderate ongoing maintenance |
| Consumer-facing dashboard with mobile users | PWA | Instant URL distribution, zero store fees, vendor-managed updates, broad reach | Low maintenance, iOS storage/push limitations |
| Hardware peripheral configuration app | Electron | Guaranteed WebHID/WebUSB/Serial access, native module support, cross-OS consistency | High development cost, requires driver/ABI management |
| Content-heavy app with frequent updates | PWA | Instant deployment, cache invalidation, no installer friction, global availability | Near-zero update cost, depends on browser compatibility |
| Cross-platform desktop with native OS integration | Electron | Full Node.js, system tray, native menus, deep OS hooks | High memory footprint, requires update infrastructure |
Configuration Template
# electron-builder.yml
appId: com.yourcompany.app
productName: YourApp
directories:
output: dist
buildResources: resources
files:
- dist/**/*
- package.json
mac:
category: public.app-category.productivity
hardenedRuntime: true
entitlements: entitlements.mac.plist
entitlementsInherit: entitlements.mac.plist
notarize: false
win:
target:
- target: nsis
arch:
- x64
certificateSubjectName: "Your Company"
linux:
target:
- AppImage
- deb
category: Development
nsis:
oneClick: false
perMachine: true
allowToChangeInstallationDirectory: true
// package.json (scripts section)
{
"scripts": {
"dev:pwa": "vite --mode pwa",
"build:pwa": "vite build --mode pwa",
"dev:electron": "vite build --mode electron && electron .",
"build:electron": "vite build --mode electron && electron-builder",
"rebuild:native": "electron-rebuild -f -w serialport",
"test:ios": "lighthouse --preset=mobile --output=json --output-path=./reports/ios.json",
"test:memory": "node scripts/soak-test.js"
}
}
Quick Start Guide
- Initialize the project: Run
npm create vite@latest your-app -- --template vanilla-ts. Install dependencies: npm i vite-plugin-pwa electron electron-builder electron-updater serialport.
- Configure platform adapters: Create
src/platform/types.ts, pwa-adapter.ts, and electron-adapter.ts. Wire the Vite alias to swap adapters based on TARGET environment variable.
- Set up CI/CD pipelines: Add GitHub Actions or GitLab CI steps for
npm run build:pwa and npm run build:electron. Include electron-rebuild before packaging. Configure code signing secrets for macOS and Windows.
- Validate cross-platform behavior: Test the PWA on iOS Safari (verify storage eviction and manual install). Test the Electron binary on Windows, macOS, and Linux. Run memory profiling and update flow validation before first release.