Mo Sharif
← ~/blog

PWA vs Electron: How I Made Codelit Installable

Every few weeks, someone emails me asking: "Is there a desktop app? I don't want to work on architecture diagrams in a browser tab."

Fair enough. When you're deep in a system design session, the last thing you want is to accidentally close the tab, or have your diagram competing for attention with 47 other tabs. You want a dedicated window. A real app.

A progressive web app (PWA) is an ordinary web app that ships a manifest file and a service worker, which lets the browser install it as a standalone window with its own icon and no address bar.

That's the route I took for Codelit, and I'd take it again over Electron.

Electron would have worked, but it costs a 200MB+ download, a second build pipeline, code signing on two platforms, and an update server to keep alive. For one person maintaining everything, that's four new jobs in exchange for removing a URL bar.

I seriously considered it. Electron is the default choice for "web app that wants to be a desktop app." VS Code, Slack, Discord, Figma (sort of), all Electron.

But the tradeoffs are rough for a solo dev:

  • 200MB+ download size. Electron bundles Chromium. Every Electron app is a full browser. For a tool that's already a web app, this feels absurd.
  • Separate build pipeline. You need electron-builder or electron-forge. Separate CI/CD. Separate release process. Code signing for macOS and Windows.
  • Auto-updates. Electron has auto-update, but you need to host the update server. Squirrel, electron-updater, or roll your own. All of them have quirks.
  • Two codebases diverge. The web version and the Electron version start as the same code, then slowly drift apart. A bug fix here doesn't make it there. Features ship at different times.

That last one killed it. Codelit has been a single Next.js codebase since the Lucidchart rage-quit that started it, and I wasn't about to grow a second one.

For a team of 10? Electron is fine. For a solo dev maintaining everything? It's a tax I can't afford.

A web app becomes installable with three things: a manifest.json describing the app, a service worker, and HTTPS. Codelit already had HTTPS, so the entire desktop app was two new files and zero changes to the application code.

The manifest tells the browser "this is an installable app." It defines the app name, icon, theme color, and display mode.

Json
{
  "name": "Codelit",
  "short_name": "Codelit",
  "description": "AI Architecture Diagram Generator",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#000000",
  "theme_color": "#000000",
  "orientation": "any",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-maskable-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ]
}

The key property is "display": "standalone". This makes the installed app open in its own window without the URL bar, tabs, or browser menu. It looks and feels like a native app.

Losing the URL bar also loses the fastest keyboard target on the screen. In a tab, people navigate by typing in the address bar; in standalone mode there is nothing to type into, which is why the ⌘K command palette with 28+ shortcuts stopped being a power-user nicety and became the way you move around.

Fonts and images use CacheFirst, API calls use NetworkFirst, and page shells use StaleWhileRevalidate. The split matters because Codelit's AI generation has to reach the network, while the canvas, templates, and UI should load on a dead connection.

For the service worker itself, I'm using next-pwa. It integrates with Next.js's build process and generates the service worker automatically.

Javascript
// next.config.js
const withPWA = require("next-pwa")({
  dest: "public",
  disable: process.env.NODE_ENV === "development",
  register: true,
  skipWaiting: true,
  runtimeCaching: [
    {
      urlPattern: /^https:\/\/fonts\.(googleapis|gstatic)\.com/,
      handler: "CacheFirst",
      options: {
        cacheName: "google-fonts",
        expiration: { maxEntries: 20, maxAgeSeconds: 60 * 60 * 24 * 365 },
      },
    },
    {
      urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|ico)$/,
      handler: "CacheFirst",
      options: {
        cacheName: "images",
        expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 * 30 },
      },
    },
    {
      urlPattern: /^https:\/\/www\.codelit\.io\/api\//,
      handler: "NetworkFirst",
      options: {
        cacheName: "api-cache",
        expiration: { maxEntries: 50, maxAgeSeconds: 60 * 5 },
      },
    },
  ],
});

module.exports = withPWA({
  // ... rest of your Next.js config
});

The same config, read as a decision table:

Asset typeStrategyWhat happens with no connectionWhy that one
Fonts (Google Fonts)CacheFirstRenders normallyThey never change; a network round trip buys nothing
Images, icons, SVGsCacheFirstRenders normallySame reason; capped at 100 entries for 30 days
/api/* responsesNetworkFirstLast response, up to 5 min oldFreshness beats speed here; cache is the fallback
Page shellsStaleWhileRevalidatePrevious version, then updatesInstant paint now, correct on the next load

Architecture generation is the one thing that can't degrade gracefully. It routes across 11 providers, and every one of them is on the far side of the network.

Intercept the browser's beforeinstallprompt event, call preventDefault() on it, stash the event, and fire prompt() from a button of your own. Browsers do surface a native install affordance, but it lives in the address bar and almost nobody looks there.

Typescript
function useInstallPrompt() {
  const [installPrompt, setInstallPrompt] = useState<any>(null);
  const [isInstalled, setIsInstalled] = useState(false);

  useEffect(() => {
    // Check if already installed
    if (window.matchMedia("(display-mode: standalone)").matches) {
      setIsInstalled(true);
      return;
    }

    const handler = (e: Event) => {
      e.preventDefault();
      setInstallPrompt(e);
    };

    window.addEventListener("beforeinstallprompt", handler);
    return () => window.removeEventListener("beforeinstallprompt", handler);
  }, []);

  const install = async () => {
    if (!installPrompt) return;
    installPrompt.prompt();
    const result = await installPrompt.userChoice;
    if (result.outcome === "accepted") {
      setIsInstalled(true);
    }
    setInstallPrompt(null);
  };

  return { canInstall: !!installPrompt, isInstalled, install };
}

The event fires when the browser decides the app meets its installability criteria: manifest, service worker, HTTPS, and some engagement heuristics it doesn't document precisely. I intercept it, suppress the default prompt, and show my own UI instead: a subtle banner at the top of the page that says "Install Codelit as a desktop app" with an install button.

The display-mode: standalone media query at the top is the bit people forget. Without it, you cheerfully offer to install the app to someone who is already running the installed app.

Safari on iOS does not implement beforeinstallprompt, so there is no event to intercept and no button you can wire up. The only install path on iPhone and iPad is the Share sheet, and the most you can do in code is detect the situation and give instructions.

Android is the easy case. Chrome shows the install button in the address bar, and my custom prompt works there too.

iOS is... different.

Typescript
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const isInStandaloneMode = window.matchMedia("(display-mode: standalone)").matches;

if (isIOS && !isInStandaloneMode) {
  showIOSInstallBanner('Install Codelit: tap the Share button, then "Add to Home Screen"');
}

It's not elegant, but it's the ceiling iOS gives you.

PlatformHow install actually happensWhat you can trigger from code
Chrome / Edge on desktopInstall icon in the address bar, or your buttonThe full beforeinstallprompt flow
Chrome on AndroidNative prompt, plus your own bannerThe full beforeinstallprompt flow
Safari on iOSShare button, then Add to Home ScreenNothing. Detect and instruct only

The offline banner is one fixed bar that appears when navigator.onLine flips to false and tells you editing still works but AI features need a connection. Nothing gets disabled and nothing gets a fake spinner.

If you lose connection mid-session, I want you to keep working, just without the AI features.

Typescript
function OfflineIndicator() {
  const [isOnline, setIsOnline] = useState(true);

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    setIsOnline(navigator.onLine);
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  if (isOnline) return null;

  return (
    <div className="fixed top-0 left-0 right-0 bg-yellow-500 text-black
                    text-center py-1 text-sm z-50">
      You're offline. Editing works, but AI features need a connection.
    </div>
  );
}

Simple, honest, non-blocking. The banner tells you what works and what doesn't. No fake loading spinners.

Panning and zooming were smooth in a browser tab and janky in the standalone PWA window on some devices, because a standalone window doesn't always get the same GPU acceleration a tab does. Two CSS declarations closed the gap.

Here's a fun one. The interactive architecture canvas I built with React Flow worked perfectly in the browser. Then I installed the app, opened a large diagram, and panning was choppy. Same code, same machine, same browser engine.

Css
.react-flow {
  will-change: transform;
}

.react-flow__node {
  will-change: transform;
  contain: layout style;
}

will-change: transform tells the browser to promote the element to its own compositing layer. contain: layout style tells it that layout changes inside the node don't affect anything outside. Together, these hints give the browser permission to optimize aggressively.

After adding these, performance in standalone mode matched the browser. Large diagrams pan and zoom smoothly on both.

Installed Codelit users average 22-minute sessions against 11 minutes for browser users, roughly double, and come back 3.5x more often. Six months in, 12% of active users have installed it, and the maintenance overhead compared to the plain web version is close to zero.

The session length difference makes sense. A dedicated window means the user is intentionally doing architecture work, not casually browsing. And there's no "accidentally close the tab" problem, because closing the app window is a deliberate action.

The return rate is the bigger win. An app icon on the dock or home screen is a constant reminder that the tool exists. Browser bookmarks don't have the same effect.

Electron is the right call when you need native file system access, system tray integration, or genuinely deep OS hooks. Codelit is a web canvas with AI features on top, and it needs none of those, so the comparison fell one way on every line that mattered to me.

DimensionPWAElectron
Install size~0 MB (uses existing browser)200+ MB
Auto-updatesAutomatic (just deploy)Needs update server
Build pipelineSame as webSeparate
CodebaseIdentical to webStarts identical, diverges
Cross-platformWorks everywhereNeeds per-platform builds
MaintenanceOne pipeline, no moreSignificant

The auto-update row is the one that decided it. When I deploy a new version of Codelit, every user, web and installed, gets it immediately. No "please update your app" prompts, no version fragmentation, no supporting an old release because a chunk of users never clicked the updater.

Which is really a delivery argument dressed up as a platform choice. The number of pipelines you maintain sets a hard ceiling on how often you can ship.

Installing Codelit takes one click. Visit codelit.io in Chrome, Edge, or any Chromium-based browser, and you'll see an install icon in the address bar. Click it. Now you've got a dedicated architecture design app on your machine.

On mobile, Android users get the install prompt automatically. iOS users: tap Share, then Add to Home Screen. (I know. Blame Apple.)

The app is the website. The website is the app. And I maintain one codebase.

If your product is already a web app and it doesn't touch the file system, the desktop version you keep meaning to build probably already exists. You just haven't written the manifest yet.

Questions people actually ask

What is a progressive web app?
A progressive web app is an ordinary web app that ships a manifest file and a service worker, which together let the browser install it as a standalone window with its own icon and no address bar. There is no separate binary and no second codebase. The installed app is the same site running in its own window, and it picks up every change the moment you deploy it.
Should I build a PWA or an Electron app?
Build a PWA if your product is already a web app and does not need native file system access, a system tray icon, or deep operating system hooks. Electron gives you all three, but it costs a download of over 200 megabytes, a separate build pipeline, code signing on both macOS and Windows, and an update server you have to host and keep running yourself.
How do I make a Next.js app installable?
Add a manifest file describing the app name, the icons, and a display mode of standalone, register a service worker, and serve the site over HTTPS. For Next.js, the next-pwa package hooks into the build and generates the service worker for you, including the runtime caching rules. No changes to the application code itself are required to make the install button appear.
Why can I not install a web app on my iPhone?
Safari on iOS does not implement the beforeinstallprompt event, so a website cannot show you an install button the way Chrome can on desktop and Android. The install path exists, but it is manual. Open the page in Safari, tap the Share button, and choose Add to Home Screen. Developers can detect iOS and display those instructions, and that is as far as it goes.
Does an installed PWA work offline?
Partly, and it depends entirely on the caching rules in your service worker. In Codelit, fonts, images, and the interface are served from cache and load with no connection, so you can keep editing a diagram offline. Anything that calls the API, including AI architecture generation, needs the network. A banner appears when the connection drops and says exactly which features stopped working.