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.
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.
// 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 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.
48 hours is tight for an auth system. Here's how we split the time:
The new sign-in handler is simpler and more predictable:
// 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;
}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.
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