#012 April 12, 2026 · 7 min read

Try-Before-Signup: IndexedDB as an Adoption Lever

Signup is a tax. For a utility app, it's the tax that breaks adoption. Here's how I made Stoka fully usable before the user ever sees an account screen.

stoka product-management indexeddb dexie pwa activation offline-first

The short version

Stoka's activation metric — tracks ≥5 items AND views ≥1 recipe within 7 days — dies behind a signup wall. So anonymous users get the full product stored in IndexedDB via Dexie.js. Signup is only triggered when the user pulls on a value lever (sync, cross-device). Server-wins on upsert handles the one realistic conflict case. Offline-first is a product decision dressed as an engineering one.

Signup is a tax. For a utility app, it’s the tax that breaks adoption.

Stoka’s activation metric is sharp: user tracks ≥5 items AND views ≥1 AI recipe within 7 days. Every decision in V1 had to be judged against whether it moves that number. Signup walls failed that test in the first five minutes of thinking about it.

The problem

Fridge tracking apps die at signup. The user journey is predictable:

  1. User hears about the app, downloads it.
  2. First screen: “Create an account to continue.”
  3. User weighs: hand over email + password to track broccoli?
  4. Bounce.

For a utility app with no social proof, no existing habit loop, no sunk cost — signup is pure friction charged upfront with zero value delivered. The activation metric requires near-zero first-use friction. A signup wall means you’re measuring activation on the 20% who made it past the wall, not the 100% who installed.

What you'll learn
01
Why signup walls on utility apps are a product failure mode, not a growth lever.
02
How to pick a conflict model (CRDT vs server-wins) without over-engineering a single-user product.
03
When "known limitation" is the right label for a behavior instead of "bug" — and why that framing matters.

The design

Anonymous users get full access. Not a sandbox, not a trial — the real product.

  • Track unlimited items.
  • View AI recipe suggestions.
  • Manage shopping list.
  • Everything stored locally via IndexedDB, wrapped in Dexie.js.
  • Zero backend calls before signup.

Signup exists for one reason: the user wants something that requires a server. Cross-device sync. Ownership guarantees (“what if I clear my browser?”). Premium features later. Signup is triggered by the user pulling on a value lever, not by the app gating them out of the product.

This inverts the usual funnel. Instead of signup → value, the path is value → more value → signup for persistence. The signup conversion is lower as a % of installs in the first session, but the activation-to-signup conversion later is dramatically higher because the user has already built context.

Sync strategy: server-wins on signup

When a user does sign up, the local IndexedDB data needs to move to Supabase. The conflict model matters.

V1 is single-user. The realistic conflict scenario: user started anonymous on their phone, then signs up on their laptop after already adding a few items there. Two fridges, one identity, small overlap.

Options I weighed:

  • CRDT (e.g. Yjs / Automerge): mathematically clean merge semantics, peer-to-peer friendly. Rejected. Adds ~40KB to bundle, introduces a whole model to reason about, and for single-user overlap it’s a cannon for a fly.
  • Last-write-wins by timestamp: cheap but clock-skew issues between client devices.
  • Server-wins on upsert: if the server has the row, keep it. If not, insert. Simple, deterministic, acceptable for V1 because real conflicts are rare and low-stakes (duplicate “Milk” entries are cheaper than a bug in merge logic).

I picked server-wins. It’s the boring answer and it’s the right answer here.

Concrete pattern

The Dexie setup and sync are small enough to show directly:

// src/lib/db.ts — Dexie setup
import Dexie, { type Table } from 'dexie';

export interface Item {
  id?: number;
  name: string;
  expiry?: string;
  category?: string;
}

export interface Recipe {
  id?: number;
  viewedAt: number;
}

class StokaDB extends Dexie {
  items!: Table<Item, number>;
  recipes!: Table<Recipe, number>;

  constructor() {
    super('stoka');
    this.version(1).stores({
      items: '++id, name, expiry, category',
      recipes: '++id, viewedAt',
    });
  }
}

export const db = new StokaDB();
// src/lib/sync.ts — on signup
import { db } from './db';
import { supabase } from './supabase';

export async function syncToSupabase(userId: string) {
  const localItems = await db.items.toArray();
  if (localItems.length === 0) return;

  await supabase.from('items').upsert(
    localItems.map(i => ({ ...i, user_id: userId })),
    { onConflict: 'user_id,name' } // server-wins
  );
}

That’s it. The interesting decision isn’t in the code; it’s in not writing more code than this.

The honest caveat

IndexedDB is per-browser. Safari on the phone and Chrome on the laptop are two different fridges for the same anonymous user.

For V1 this is acceptable. The target is single-device casual users — someone picking up the PWA on their phone in the kitchen, not a power user syncing across five clients. The moment a user wants their fridge on a second device is the moment they have a concrete reason to sign up. The limitation is the conversion trigger.

I logged this explicitly in brain/vivre-cards.md — the append-only decision log — as a known limitation, not a bug. That distinction matters: it tells future-me not to “fix” it by adding a signup wall.

Broader principle

Offline-first and anonymous-first sound like engineering preferences. They’re not. They’re product decisions dressed as engineering ones.

  • If activation depends on zero-friction first use, a signup wall costs you the metric.
  • If your core loop works without the server, forcing a server call is a product choice to fail more users.
  • If your conflict model is “single user across rare device changes,” using a heavy merge system is a product choice to slow down your own bundle.

Each of these surfaced during Shaka’s Observation Haki pass — the 7-lens PRD framework — specifically Lens 7: Fogwhat do you not know yet, and what’s the cheapest way to de-risk? For Stoka, the Fog question “will users tolerate a signup wall?” had a cheaper answer than “ship a wall and A/B test”: just don’t ship the wall. The burden of proof is on the feature that adds friction, not on the feature that removes it.

I unpack the full 7-lens pass in the seven lenses post.

Lesson

Offline-first is a product decision dressed as an engineering one. If activation depends on zero-friction first use, signup walls cost you everything.

The engineering implementation is small — Dexie + a 15-line sync function. The product implication is large: the user owns their data locally from second one, and the app earns the right to ask for an account by delivering value before it asks.


Key Takeaways

  1. Put the value before the password. A utility app with no habit loop cannot tax the user upfront. Ship the full product to anonymous users and let a concrete value lever (sync, ownership) be the thing that triggers signup.
  2. Pick the boring conflict model unless you have a real reason not to. CRDTs are beautiful and expensive. Server-wins on upsert is ugly and correct for single-user-across-rare-devices. Match the conflict model to the actual conflicts.
  3. “Known limitation” and “bug” are different labels with different fixes. IndexedDB being per-browser is a limitation that becomes the signup trigger. Logging it as a limitation protects you from “fixing” it the wrong way later.

Satellite: Shaka (PRD) · Morgans (this post) · Pipeline: DISCOVERY — Observation Haki → Morgans