Stop Writing Referral Tables: How to Build a Stateless Referral System with Redis and FastAPI

Stop Writing Referral Tables: How to Build a Stateless Referral System with Redis and FastAPI

The software engineering community's obsession with relational database normalization is quietly ruining growth loops. Every time an indie hacker or growth engineer builds a referral program, they default to the same bloated pattern: a new referrals table, complex foreign-key constraints linking users to campaigns, row-level locks, and multi-step state machines tracking whether a reward has been claimed, pending, or revoked.

For an application processing thousands of daily operations—like my AI resume tailoring platform, SwitchWithAI—writing transactional state changes for every single referral click and conversion is an unnecessary database tax. It introduces write contention, clutters your production schema with transient growth data, and creates massive security vulnerabilities where users can exploit race conditions to stack rewards.

What if your referral architecture didn't touch your primary database at all until a conversion actually settled?

Here is the exact stateless, non-stackable referral system architecture I committed to the switchwithai-backend codebase, powered by FastAPI and Redis.


Why Relational Referral Tables Fail Under Scale

When you design a referral system using traditional SQL paradigms, you immediately run into three structural bottlenecks:

  1. Database Bloat from Transient Intent: 90% of referral link clicks never result in a completed signup or a paid conversion. Storing every click, token generation, and intermediate state in your primary relational store (like PostgreSQL or AlloyDB) introduces dead rows and unindexed noise.
  2. Race Conditions and Reward Stacking: If your validation logic checks a database table for existing user credits (SELECT * FROM credits WHERE user_id = X), two simultaneous API requests can bypass your guardrails before the write transaction commits. Users quickly figure out how to loop requests and stack free runs.
  3. Coupling Growth Logic to Core Business Data: Your primary database should hold immutable user profiles, authentication state, and core product data. Growth experiments—like our recent launch offer capping referral rewards at 15 days or 25 fast runs exclusively for free accounts—should live where they can expire, evict, and self-clean without running heavy ALTER TABLE migrations.

To fix this, we need to decouple referral tracking entirely from our primary data store by leveraging Redis atomic sets and TTLs.


The Stateless Referral Architecture

In the system we deployed for SwitchWithAI, the referral token, its ownership, and the redemption status are managed entirely in-memory via Redis. FastAPI handles the incoming validation endpoints with async dependency injection, ensuring sub-10ms response times without placing a single lock on our primary database.

Here are the core rules of our production referral engine:

  • Non-Stackable: A user can only claim one referral reward code globally. Once redeemed, a Redis bitmap or set lock prevents subsequent claims.
  • Free Account Restriction: Paid accounts cannot apply referral boosts—enforced instantly via a Redis key lookup before any heavy business logic executes.
  • Strict Reward Limits: Rewards are hard-capped (e.g., 15 days or 25 runs limited to our Fast tier).

Implementing the FastAPI and Redis Engine

Here is a stripped-down, production-grade implementation of the referral redemption endpoint using FastAPI and redis-py.

import redis.asyncio as aioredis
from fastapi import FastAPI, HTTPException, Depends
from pydantic import BaseModel

app = FastAPI()

# Initialize Redis connection pool
redis_client = aioredis.from_url("redis://localhost:6379", decode_responses=True)

class ReferralRedeemRequest(BaseModel):
    user_id: str
    referral_code: str

async def get_redis():
    async with redis_client as client:
        yield client

@app.post("/api/v1/referral/redeem")
async def redeem_referral(payload: ReferralRedeemRequest, redis: aioredis.Redis = Depends(get_redis)):
    user_id = payload.user_id
    code = payload.referral_code

    # 1. Check if the user has already redeemed any referral (Non-stackable enforcement)
    has_redeemed_key = f"user:{user_id}:has_redeemed_referral"
    already_redeemed = await redis.get(has_redeemed_key)
    
    if already_redeemed:
        raise HTTPException(status_code=400, detail="Referral reward already claimed. No stacking allowed.")

    # 2. Validate the referral code existence and owner
    code_owner_key = f"referral:code:{code}"
    owner_id = await redis.get(code_owner_key)
    
    if not owner_id:
        raise HTTPException(status_code=404, detail="Invalid or expired referral code.")

    if owner_id == user_id:
        raise HTTPException(status_code=400, detail="You cannot refer yourself.")

    # 3. Verify target user is on a free account (Business logic guardrail)
    is_free_account = await redis.get(f"user:{user_id}:is_free_tier")
    if not is_free_account or is_free_account == "false":
        raise HTTPException(status_code=403, detail="Referral rewards are restricted to free accounts.")

    # 4. Atomic transaction using Redis pipeline to prevent race conditions
    async with redis.pipeline(transaction=True) as pipe:
        # Mark user as having redeemed a referral permanently
        pipe.set(has_redeemed_key, "1")
        
        # Grant the reward: e.g., 25 fast runs / 15 days extension
        pipe.hincrby(f"user:{user_id}:credits", "fast_runs", 25)
        
        # Track referral conversion count for the code owner (for analytics/leaderboards)
        pipe.hincrby(f"referral:stats:{owner_id}", "successful_conversions", 1)
        
        execute_results = await pipe.execute()

    return {
        "status": "success",
        "message": "Referral reward applied successfully.",
        "reward": {"fast_runs": 25, "duration_days": 15}
    }

Why This Architecture Wins

By moving this logic out of PostgreSQL and into Redis, we achieve three critical engineering outcomes:

  1. Zero Database Bloat: Our relational tables remain clean, containing only verified, paying, or active user states. Transient referral sharing loops live and die in fast memory.
  2. Built-in Concurrency Safety: Redis pipelines execute commands atomically. If two requests hit the /redeem endpoint at the exact same millisecond for the same user, Redis serialization ensures has_redeemed_key is set on the first execution, causing the second to fail gracefully before any reward is granted.
  3. Instant Expiration and Throttling: If we want to run time-bound referral campaigns (like our recent SwitchWithAI feature drop), we can attach a TTL directly to the referral:code:{code} keys, removing the need for cron jobs to clean up expired database rows.

Takeaways

Stop treating growth engineering as an excuse to pollute your core database schemas. State machines belong in your application layer and in-memory stores; your persistent database should be reserved for the core domain model.

If you're building viral loops for your own SaaS, keep your tables lean, let Redis handle atomic concurrency, and restrict your rewards at the caching layer.

If you want to see this referral engine running live in production alongside our AI resume tailoring pipelines, check out SwitchWithAI.


Related Reading