Designing a Free Tier That Doesn’t Bankrupt You: Quota, Throttling & Upgrade Prompts

Most free tiers are either too generous and bleed money, or too stingy and push users away before they ever convert. Getting this balance right is an engineering problem as much as a product one — and most articles about it stop at "use Redis for rate limiting." That’s not enough.

This guide walks through the full stack: quota design, enforcement mechanics, graceful throttling, and upgrade prompts that feel natural rather than predatory. We’ll build real code you can drop into a FastAPI service backed by PostgreSQL and Redis.


Why Free Tiers Fail

The naive approach is a cron job that resets a counter in a database column every month. That works until it doesn’t — someone finds the race condition, you discover you’ve been serving 10× your expected traffic because the counter check and the counter increment aren’t atomic, or you realize your upgrade flow just sends the user to a pricing page with zero context about what they actually hit.

The other failure mode is technical correctness at the expense of UX. Hard 429s with no explanation, quota walls with no preview of what they’d get on a paid plan, and prompts that interrupt critical workflows. Users don’t upgrade when they’re frustrated — they churn.

A production-grade free tier needs:

  • Atomic quota enforcement — check and increment must be one operation
  • Per-resource granularity — not just one global counter
  • Burst tolerance — free users shouldn’t get hammered for a legitimate spike
  • Context-aware upgrade prompts — triggered at the right moment, with the right message

The Data Model

Start with PostgreSQL. You need two things: the user’s plan/limits, and their current usage.

-- Plans table: defines what each tier is allowed
CREATE TABLE plans (
    id          SERIAL PRIMARY KEY,
    name        TEXT NOT NULL UNIQUE,           -- 'free', 'pro', 'enterprise'
    price_cents INTEGER NOT NULL DEFAULT 0,
    limits      JSONB NOT NULL DEFAULT '{}'     -- flexible limit definitions
);

-- Example plan limits stored as JSONB:
-- {"api_calls_per_month": 1000, "exports_per_day": 5, "storage_bytes": 104857600}

-- User plan assignments
CREATE TABLE user_plans (
    user_id     UUID PRIMARY KEY REFERENCES users(id),
    plan_id     INTEGER NOT NULL REFERENCES plans(id),
    started_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    renews_at   TIMESTAMPTZ                        -- NULL for free tier
);

-- Usage ledger: append-only, partitioned by month
CREATE TABLE usage_events (
    id          BIGSERIAL,
    user_id     UUID NOT NULL,
    resource    TEXT NOT NULL,   -- 'api_call', 'export', 'storage_byte'
    amount      INTEGER NOT NULL DEFAULT 1,
    occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (occurred_at);

-- Monthly partition (automate this with pg_partman in prod)
CREATE TABLE usage_events_2026_05
    PARTITION OF usage_events
    FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');

-- Materialized quota cache: don't count rows on every request
CREATE TABLE quota_usage (
    user_id     UUID NOT NULL,
    resource    TEXT NOT NULL,
    window      TEXT NOT NULL,   -- '2026-05' or '2026-05-25'
    used        INTEGER NOT NULL DEFAULT 0,
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (user_id, resource, window)
);

The limits JSONB column on plans is intentional. Rigid columns force schema migrations every time you add a resource type. JSONB lets you define new limits without a deploy.


Redis for Hot-Path Enforcement

Hitting PostgreSQL on every API request to check quotas is slow and fragile under load. Redis handles the enforcement; PostgreSQL handles the source of truth.

The pattern: a sliding window counter in Redis, backed by a periodic sync to PostgreSQL.

# quota.py
import redis.asyncio as redis
from datetime import datetime, timezone

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

async def check_and_increment(
    user_id: str,
    resource: str,
    window: str,   # e.g. "2026-05" for monthly, "2026-05-25" for daily
    limit: int,
    amount: int = 1,
) -> tuple[bool, int]:
    """
    Atomically check quota and increment if allowed.
    Returns (allowed: bool, current_usage: int).
    
    Uses a Lua script to make check+increment atomic.
    """
    key = f"quota:{user_id}:{resource}:{window}"

    # Lua script: check the current value, increment only if under limit
    lua_script = """
        local current = tonumber(redis.call('GET', KEYS[1])) or 0
        local limit = tonumber(ARGV[1])
        local amount = tonumber(ARGV[2])
        local ttl = tonumber(ARGV[3])

        if current + amount > limit then
            return {0, current}
        end

        local new_val = redis.call('INCRBY', KEYS[1], amount)
        -- Set TTL only on first write; don't reset it on every call
        if new_val == amount then
            redis.call('EXPIRE', KEYS[1], ttl)
        end
        return {1, new_val}
    """

    # TTL: seconds until the window expires
    now = datetime.now(timezone.utc)
    if len(window) == 7:  # monthly: YYYY-MM
        next_month = (now.replace(day=1) + __import__('datetime').timedelta(days=32)).replace(day=1)
        ttl = int((next_month - now).total_seconds()) + 3600  # +1h buffer
    else:  # daily: YYYY-MM-DD
        midnight = now.replace(hour=0, minute=0, second=0) + __import__('datetime').timedelta(days=1)
        ttl = int((midnight - now).total_seconds()) + 300

    result = await r.eval(lua_script, 1, key, limit, amount, ttl)
    allowed, current = bool(result[0]), int(result[1])
    return allowed, current

The Lua script is the critical piece. Redis executes it atomically — no other client can interleave between the GET and INCRBY. This kills the race condition that plagued the naive approach.


The Enforcement Middleware

In FastAPI, wrap quota checking in a dependency. Keep it out of your route handlers.

# dependencies.py
from fastapi import Depends, HTTPException, Request, status
from typing import Annotated

async def get_current_user(request: Request):
    # Your auth logic here
    return request.state.user

async def enforce_quota(
    resource: str,
    amount: int = 1,
    window_type: str = "monthly",
):
    """Factory that returns a FastAPI dependency for a specific resource."""
    
    async def _check(user=Depends(get_current_user)):
        from datetime import datetime, timezone
        from .quota import check_and_increment
        from .plans import get_user_limits  # fetches from cache/DB

        now = datetime.now(timezone.utc)
        window = now.strftime("%Y-%m") if window_type == "monthly" else now.strftime("%Y-%m-%d")
        
        limits = await get_user_limits(user.id)
        limit = limits.get(resource)
        
        if limit is None:
            # No limit defined for this resource on their plan — allow
            return user

        allowed, current = await check_and_increment(
            user_id=str(user.id),
            resource=resource,
            window=window,
            limit=limit,
            amount=amount,
        )

        if not allowed:
            raise HTTPException(
                status_code=status.HTTP_429_TOO_MANY_REQUESTS,
                detail={
                    "code": "QUOTA_EXCEEDED",
                    "resource": resource,
                    "limit": limit,
                    "used": current,
                    "window": window,
                    "upgrade_url": f"/upgrade?reason=quota&resource={resource}",
                },
                headers={"Retry-After": "0"},  # quota, not rate limit — no retry makes sense
            )
        
        return user

    return _check


# routes.py
from fastapi import APIRouter, Depends
from .dependencies import enforce_quota

router = APIRouter()

@router.post("/export")
async def create_export(
    _=Depends(enforce_quota("exports_per_day", window_type="daily")),
):
    # If we're here, quota was checked and incremented atomically
    return {"status": "export_started"}

Notice the detail payload on the 429. It’s structured JSON, not a string. Your frontend can parse upgrade_url and render a proper modal instead of just showing an error toast.


Burst Tolerance with Token Bucket

Monthly quotas prevent overuse over time. They don’t handle request bursts — a free user hammering your API 100 times in a second is a different problem from a user who made 1001 API calls this month.

Use a token bucket for per-second/per-minute rate limits, independent of quota.

# rate_limiter.py
async def token_bucket_check(
    user_id: str,
    bucket_key: str,
    capacity: int,       # max tokens (burst ceiling)
    refill_rate: float,  # tokens added per second
) -> tuple[bool, float]:
    """
    Returns (allowed, tokens_remaining).
    Uses Redis + Lua for atomicity.
    """
    key = f"bucket:{user_id}:{bucket_key}"
    now_ms = int(__import__('time').time() * 1000)

    lua = """
        local key = KEYS[1]
        local capacity = tonumber(ARGV[1])
        local refill_rate = tonumber(ARGV[2])  -- tokens per ms
        local now = tonumber(ARGV[3])

        local data = redis.call('HMGET', key, 'tokens', 'last_refill')
        local tokens = tonumber(data[1]) or capacity
        local last_refill = tonumber(data[2]) or now

        -- Refill based on elapsed time
        local elapsed = now - last_refill
        tokens = math.min(capacity, tokens + elapsed * refill_rate)

        if tokens < 1 then
            -- Update last_refill so time keeps ticking even when blocked
            redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
            redis.call('EXPIRE', key, 3600)
            return {0, tokens}
        end

        tokens = tokens - 1
        redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
        redis.call('EXPIRE', key, 3600)
        return {1, tokens}
    """

    result = await r.eval(lua, 1, key, capacity, refill_rate / 1000.0, now_ms)
    return bool(result[0]), float(result[1])

Free tier users get a smaller bucket and slower refill. Paid users get a larger bucket. This naturally smooths out the experience — a free user can still do a short burst, just not an infinite one.


Upgrade Prompts That Don’t Suck

The technical enforcement is the easy part. The prompt UX is where most products leave conversion on the table.

Bad upgrade prompt: a modal that says "You’ve hit your limit. Upgrade to Pro."

Good upgrade prompt: contextual, specific, and timed to the moment of highest intent.

When to Trigger

Don’t wait for the wall. Trigger at 80% of quota:

# In your quota check response, include usage context
async def get_quota_status(user_id: str, resource: str) -> dict:
    limits = await get_user_limits(user_id)
    limit = limits.get(resource, 0)
    
    if not limit:
        return {"limited": False}

    now = datetime.now(timezone.utc)
    window = now.strftime("%Y-%m")
    used = int(await r.get(f"quota:{user_id}:{resource}:{window}") or 0)
    
    pct = used / limit if limit else 0
    return {
        "resource": resource,
        "used": used,
        "limit": limit,
        "pct": round(pct, 3),
        "warn": pct >= 0.8,    # soft warning
        "blocked": pct >= 1.0, # hard block
    }

Return this in your API response headers so the frontend can act without a separate call:

# In your route handler or middleware
@router.post("/export")
async def create_export(response: Response, user=Depends(enforce_quota("exports_per_day"))):
    status = await get_quota_status(str(user.id), "exports_per_day")
    response.headers["X-Quota-Used"] = str(status["used"])
    response.headers["X-Quota-Limit"] = str(status["limit"])
    response.headers["X-Quota-Warn"] = str(status["warn"]).lower()
    # ...

Your frontend reads X-Quota-Warn: true and renders an inline banner — not a blocking modal, just a nudge — while the user is still in a positive state (their action succeeded).

The Upgrade URL Schema

Make the upgrade URL carry context. Your pricing page should receive it and personalize the CTA:

/upgrade?reason=quota&resource=exports_per_day&used=4&limit=5&source=api_header

Now your pricing page can say: "You’ve used 4 of your 5 daily exports. Pro gives you 100." That’s a different conversion than a generic "Upgrade to unlock more features."

// pricing-page.js (React snippet)
const params = new URLSearchParams(window.location.search);
const resource = params.get("resource");
const used = params.get("used");
const limit = params.get("limit");

const messages = {
  exports_per_day: `You used ${used} of ${limit} daily exports today. Pro removes this limit.`,
  api_calls_per_month: `${used} of ${limit} API calls used this month. Upgrade before you hit the wall.`,
};

const headline = messages[resource] || "Unlock the full platform";

Gotchas

Clock skew with distributed Redis. If you’re running Redis Cluster or Sentinel, your TTL calculations need to account for the fact that time.time() on your app server and the time Redis uses for EXPIRE may drift. Use TIME command in your Lua script to get Redis’s own clock if precision matters.

The cold start problem. On first deploy, Redis is empty. Your Lua scripts return nil for existing users — make sure your default values are sane. The or capacity pattern in the token bucket script handles this; verify your quota script does too.

Sync lag between Redis and PostgreSQL. If Redis goes down and comes back up, all counters reset to zero. Users get a surprise quota refill. Sync to PostgreSQL every N increments or on a 60-second timer, and on startup, seed Redis from PostgreSQL for active users.

# On app startup, warm Redis from PostgreSQL for recent active users
async def warm_quota_cache():
    rows = await db.fetch("""
        SELECT user_id, resource, window, used
        FROM quota_usage
        WHERE window = to_char(NOW(), 'YYYY-MM')
          AND updated_at > NOW() - INTERVAL '7 days'
    """)
    pipe = r.pipeline()
    for row in rows:
        key = f"quota:{row['user_id']}:{row['resource']}:{row['window']}"
        pipe.set(key, row["used"], keepttl=True)
    await pipe.execute()

Timezone edge cases. Monthly windows based on strftime("%Y-%m") shift at midnight UTC. A user in UTC+9 hits their "new month" at 9 AM local time. That’s fine if you’re consistent about it, but communicate it — "resets on the 1st at midnight UTC" is better than leaving users confused.

Plan caching staleness. If you cache user plan limits aggressively and a user upgrades, they’ll still see the old limits until the cache expires. Use cache keys that include a plan version or bust them explicitly on upgrade events.

Don’t throttle health checks. If your quota middleware wraps everything, make sure unauthenticated routes (health checks, public endpoints) bypass it. Sounds obvious; you’ll forget it at 2 AM when your load balancer health checks start 429ing.


Production Checklist

Before you ship this:

  • Observability: emit a metric for every quota check — quota.checked, quota.blocked, quota.warned. You need to know which resources are actually causing friction and which limits are set too tight or too loose.
  • Admin override: have a way to temporarily raise a specific user’s limit without a code deploy. A simple quota_overrides table keyed on (user_id, resource) with an expiry works fine.
  • Idempotency on retries: if the client retries a failed request, don’t double-count. Include a request ID in your quota key if the operation is idempotent from a billing perspective.
  • Audit log: for anything money-adjacent, log every quota block event to PostgreSQL synchronously. Redis is not your audit trail.
  • Graceful degradation: if Redis is unreachable, decide in advance whether you fail open (allow requests, risk abuse) or fail closed (block requests, risk outage). For most SaaS products, fail open with logging is the right call — a few minutes of unlimited access is cheaper than downtime.

The Bigger Picture

A free tier is a bet on future revenue. Every dollar you spend serving free users is an acquisition cost — the same as an ad spend, except you’re paying in infrastructure. The quota system isn’t about being stingy; it’s about making that bet sustainable while giving users enough room to discover real value.

The prompts matter as much as the limits. A user who hits a quota wall and sees exactly what they’d unlock on a paid plan, in the context of the thing they were just trying to do, converts at a meaningfully higher rate than a user who gets a generic 429 and goes looking at competitors.

Get the atomicity right, seed your caches on startup, instrument everything, and make your upgrade flow carry context. That’s the whole system.

Leave a comment

👁 Views: 10,648 · Unique visitors: 14,506