1
1 Comment

I shipped Leap β€” an Arc-style sidebar for Chrome. Here's the stack and the 3 decisions that shaped it.

Hey IH πŸ‘‹

I just pushed Leap to the Chrome Web Store β€” a vertical-sidebar extension that gives regular Chrome the Arc Browser experience: Spaces, vertical tabs, pinned sites, nested folders, keyboard shortcuts, cloud sync. Tagline: "Leap between spaces."

I didn't want to switch browsers. I wanted Arc's workflow inside the browser I already use. So I built it.

Posting here as a build-in-public write-up β€” the three architectural decisions that mattered, the stack, and the traps I wish someone had warned me about. Would love your feedback.


Decision 1: Spaces = native Chrome Tab Groups (not a custom overlay)

The obvious move is to render your own tab list in a React sidebar and pretend Chrome's native UI doesn't exist. I didn't do that.

Every Leap "Space" is a real chrome.tabGroups group under the hood. Switching a space means: collapse every group β†’ expand the target β†’ activate its first tab.

Why it matters: in MV3, chrome.tabs.hide() is not available to regular extensions. You cannot hide tabs to emulate spaces. You can collapse tab groups β€” and a collapsed group visually disappears from the tab strip while keeping the tabs alive. That single API constraint drove the whole architecture.

Upside: zero desync between "what Leap thinks" and "what Chrome thinks". Downside: you're bound to tab-group semantics (one group per tab, group color enum, etc.) forever.

Decision 2: Local-first with debounced background sync

All writes go to chrome.storage.local first. The service worker then debounces a sync to the backend. The UI never blocks on the network.

  • Zustand for local state (spaces, tabs, settings, auth)
  • TanStack Query for server state (sync, sessions, AI calls)
  • A single sync boundary β€” components never call fetch directly, they only go through query hooks in queries/

The mental model: local is the source of truth at read time, server is the source of truth at reconcile time. It's the only pattern that gives you the Arc-like instant feel while still having cloud sync across devices.

Decision 3: Firebase + chrome.identity = one login, forever

This was the most pleasant surprise. The flow:

  1. chrome.identity.getAuthToken() grabs a Google OAuth token from the signed-in Chrome profile.
  2. Exchange it via signInWithCredential() into a Firebase session.
  3. Firebase persists the session in IndexedDB.

Net result: the user clicks "Sign in with Google" once. That's it. Forever. No token refresh UI, no re-login after a restart, no cookie jar issues. For a Chrome extension this is gold β€” auth friction was my biggest fear and it turned out to be a 40-line integration.


The full stack

Extension: React 18, TypeScript strict (no any), Tailwind 3, Zustand, TanStack Query, Framer Motion (spring-based, Apple-aesthetic micro-animations), Vite + CRXJS, Manifest V3.

Server: Go 1.24, Fiber v3, goqu v9 (SQL builder) + pgx v5, Neon Postgres, Firebase Admin, go-playground/validator, log/slog. Plain structs as models, manual DI, golang-migrate for schema.

Monorepo: Turborepo + pnpm, a [@leap](/leap)/shared package for the TypeScript types (Go produces the matching JSON via struct tags).

Infra: Neon (DB), Railway (Go server, Docker), Firebase (auth), Biome for the TS side.

Things that bit me

  • Manifest V3 service workers die. Any in-memory state in the background script is gone the moment Chrome decides to kill the worker. Everything that needs to persist goes through chrome.storage. I learned this the hard way with a debounce timer that silently stopped firing.
  • Tab group APIs are flaky under rapid switching. Collapsing and expanding groups in quick succession can leave the window in a weird state. I added a 40ms guard between operations β€” problem gone.
  • Framer Motion in an extension sidebar is fine, but watch the bundle. CRXJS does tree-shake well, but Framer is heavy. I import only from the core package and it stays under budget.
  • Folders nested 3 levels deep required a parentId + depth schema β€” easier to enforce depth at write time than to chase orphaned nodes later.

Where it is now

Leap is live on the Chrome Web Store right now: πŸ‘‰ Install Leap

Free, no account needed to try locally β€” sign in only if you want cross-device sync.

The ask

  • Try it for a day and tell me where it feels wrong. Arc users especially β€” what did I miss?
  • I'm debating opening the server as a self-hostable option. Is that something any of you would actually run, or is it just nerd candy?
  • If you've shipped a Chrome extension to 1k+ installs, I would love to hear what you did for onboarding and retention β€” that's my next hill to climb.

Happy to answer anything about the stack, the MV3 constraints, or the build-in-public journey in the comments. πŸ™

β€” Roman

on April 17, 2026
  1. 1

    I actually know a few Chrome extension founders who've scaled past 1k installs personally. Happy to ask them if they'd answer some of your questions about onboarding and retention for free.