Quota gates for freemium SaaS with Stripe and Supabase: the pattern I use in CV Builder
Every freemium SaaS lives or dies on the quota gate. The free tier has to be useful enough that people try it. The paid tier has to feel like the natural next step. The wiring between the two is where a lot of indie SaaS quietly leaks money.
I wired up the quota system for CV Builder, my freemium resume SaaS where free users get a fixed number of PDF exports per month and paid users get unlimited. Below is the pattern I trust, the trade offs I made, and the failure modes you should plan for.
The shape of the system
Three tables in Supabase, in addition to whatever Supabase Auth already gives you:
plans (slug, monthly_quota, stripe_price_id, ...)
subscriptions (user_id, plan_slug, status, current_period_end, ...)
usage_events (user_id, kind, occurred_at, metadata jsonb)
plans is seeded once. subscriptions is written by your Stripe webhook handler. usage_events is append only, written every time a user does a metered action.
The quota check is a single SQL function:
create or replace function public.remaining_quota(uid uuid)
returns int
language sql
stable
as $$
select
coalesce(p.monthly_quota, 0) - count(e.id)::int
from public.plans p
join public.subscriptions s on s.plan_slug = p.slug and s.user_id = uid
left join public.usage_events e
on e.user_id = uid
and e.kind = 'pdf_export'
and e.occurred_at >= date_trunc('month', now())
where s.status in ('active', 'trialing')
group by p.monthly_quota;
$$;Server actions call remaining_quota(auth.uid()) before the export, and the export route writes a usage_events row immediately after the PDF is generated.
The race that always bites
The naive pattern is "read quota, generate PDF, write usage_event." Under concurrent requests, two PDFs slip through because both read the same remaining count.
The fix is to write the usage event first, then check whether it should have been written. The fastest version:
with inserted as (
insert into usage_events (user_id, kind)
values ($1, 'pdf_export')
returning id
)
select
case
when public.remaining_quota($1) >= 0 then true
else false
end as allowed,
(select id from inserted) as event_id;If the function returns allowed = false, you delete the usage_event you just inserted and reject the request. The window where both rows exist briefly is fine. The window where the user gets a PDF that should not have been generated is not.
Stripe webhooks are the second source of bugs
The webhook handler updates subscriptions. It must be idempotent. Stripe will retry. Your handler will be invoked twice for the same event. The fix is a tiny processed_events table keyed by event.id that the handler checks before doing anything.
The webhook also has to handle the cases nobody talks about in the docs. The user upgrades mid month, then downgrades two days later. The user pays, then disputes the charge. The user's card fails for the renewal. Every one of those is a status transition you have to map to your subscriptions table or your quota math breaks.
What the freemium decision really costs
People forget that every gate is a place users churn. The PDF export gate in CV Builder is positioned at the moment of highest user intent, right after they finish editing. That is intentional. A gate earlier in the flow would drive sign ups but tank conversion to paid.
If your team is considering freemium, the right question is not "where do we put the gate" but "where in our flow is the user already proving they value the product." Gate there.
What I would tell hiring managers
Billing code looks simple until you have to debug it. The engineers worth hiring are the ones who treat webhook handlers as critical infrastructure, write the idempotency layer first, and test the renewal failure path before the happy path.
Currently open to remote Senior and Staff roles. If your team is building billing or quota infrastructure on Stripe and Supabase, would love to talk.
Md. Tausif Hossain leads engineering at DevTechGuru and ships SaaS independently as TechnicalBind. He is open to remote Senior and Staff roles. Reach him at tausif1337.dev.
Newsletter
Get new posts in your inbox.
Honest essays on engineering, leadership, and the things I’m figuring out. No spam, ever.