KIQA.DEV
Back to writing
Architecture
Mar 14, 2026
9 min read
by Kristian Gjergji

Why I rebuilt Spindare's login system in 48 hours

The old setup worked fine until it didn't. An honest account of the decision, the rebuild, and what I'd do differently.

In March 2026, about two months before Spindare's planned iOS launch, we rebuilt the entire authentication flow from scratch in 48 hours. This is the honest account of why we did it, how it went, and what I'd do differently if I had to start over.

What we had

Our original auth system used Clerk for session management with a custom user profile layer on top in Supabase. On paper it was clean: Clerk handles the session, our database handles the user data, and a webhook keeps them in sync.

In practice, the webhook sync was the problem. Clerk fires a webhook on user creation. Our Supabase edge function received it and wrote the user record. But under certain conditions (bad network, cold-start latency on the edge function, or just Clerk being slow), the record wasn't there when the user first logged in. The app would throw.

typescript
// The failure path: simplified
// 1. User signs up via Clerk
// 2. Clerk fires webhook → Supabase Edge Function
// 3. User is redirected to app: Clerk session exists
// 4. App fetches user profile from Supabase
// 5. Profile doesn't exist yet (webhook hasn't fired)
// 6. App crashes or shows blank screen

// We had a retry loop but it made the UX worse, not better
const profile = await getUserProfile(clerkId);
if (!profile) {
  // Show loading spinner for up to 5 seconds
  // If still no profile → error screen
  // Users: confused, assumed the app was broken
}

We patched it three times. Each patch made the code harder to reason about. The race condition never fully went away. It just became rarer.

The decision to rebuild

The final straw was a TestFlight session where a reviewer hit the bug twice in one day. It was two months before launch. We had time to fix it properly, or we could ship with a system we didn't fully trust.

We chose to rebuild. The new approach: make Supabase the single source of truth for user identity. Clerk stays for session management and social login UX, but on first login we create the user profile synchronously (inside the sign-in handler, before the user reaches the app), not via webhook.

The rebuild

48 hours is tight for an auth system. Here's how we split the time:

  1. 1.Day 1 morning: Architecture review. Map every place in the codebase that touches auth. Identify what has to change vs what can stay.
  2. 2.Day 1 afternoon: Write the new sign-in handler. Test it manually with fresh accounts, existing accounts, and edge cases (user exists in Clerk, not in DB; user exists in DB, not in Clerk).
  3. 3.Day 1 evening: Update all downstream consumers. Everywhere the app fetched a user profile, change the assumptions about what's guaranteed to exist.
  4. 4.Day 2 morning: Remove all the old webhook code. Delete the retry loops. Remove the edge function.
  5. 5.Day 2 afternoon: Full regression test on TestFlight. Fix two bugs we found (one in the profile photo upload flow, one in the onboarding redirect logic).
  6. 6.Day 2 evening: Ship to all testers.

The new sign-in handler is simpler and more predictable:

typescript
// New approach: synchronous profile creation at sign-in
async function handleSignIn(clerkUserId: string, email: string) {
  // Check if profile already exists
  let profile = await db.users.findUnique({
    where: { clerk_id: clerkUserId }
  });

  // Create it if not, guaranteed before app load
  if (!profile) {
    profile = await db.users.create({
      data: {
        clerk_id: clerkUserId,
        email,
        username: generateUsername(email),
        created_at: new Date(),
      }
    });
  }

  // Profile is guaranteed to exist from here
  return profile;
}

What happened after

The race condition bug has not appeared once since the rebuild. The auth code is shorter, easier to read, and has no retry logic. Testers stopped mentioning login issues entirely.

The one downside: the sign-in handler now does a database write on every login. For a user who logs in frequently, this is an unnecessary database round-trip most of the time. The `findUnique` before the `create` prevents duplicate writes, but the query still happens. At scale this would need caching or a different approach. For now, it's fast enough.

What I'd do differently

Skip the webhook approach entirely from the start. For any application where a user record needs to exist before the user reaches the app, create it synchronously at first login. Webhooks are fine for secondary side effects (sending a welcome email, creating analytics events), but not for data that the app depends on being there immediately.

The best async operation is the one you don't need. If your app requires a piece of data to exist before the user sees anything, create it synchronously. Retry logic is a sign the architecture needs a rethink, not more retries.

Written by

Kristian Gjergji

Developer · Kosovo / Italy

Work with me →
Chat on WhatsApp