Free plans and limits: a practical guide for developers
Running a freemium product means balancing generosity with sustainability. If your free tier is too permissive, you'll hemorrhage infrastructure costs and users never upgrade. If it's too restrictive, you'll frustrate your most engaged users and they'll churn before you convert them.
On Day 66 of our payment engineering journey, we're tackling the operational and technical challenges of implementing free plans and limits that actually work. We'll cover how to track usage per account, enforce both soft and hard limits, and design upgrade prompts that convert without feeling manipulative.
Understanding the role of clear usage limits
A clear usage limit is your first line of defense against abuse and cost overruns. Users need to know exactly what they're getting and where the boundaries are. Vagueness ("plenty of API calls") creates frustration when they hit an invisible wall. Clarity ("500 API calls per month") creates trust and helps users plan their usage.
Start by defining your limits around the metrics that correlate most directly with your infrastructure costs. For an API-first product, that's usually requests per month or requests per second. For file storage, it's gigabytes. For seat-based tools, it's active team members. Don't limit based on what feels arbitrary—limit based on what you actually pay for.
At the product level, communicate limits prominently in your pricing page, onboarding, and settings dashboard. Users should never be surprised by a limit they've hit. In your API documentation and client libraries, include the current limit alongside the remaining quota.
Here's a typical pricing tier structure we've seen work well in practice:
| Plan | API Calls/Month | Storage | Support | Cost |
|---|---|---|---|---|
| Free | 10,000 | 1 GB | Community | $0 |
| Starter | 100,000 | 50 GB | $29/mo | |
| Pro | 1,000,000 | 500 GB | Priority | $99/mo |
The key is that each tier should feel like a meaningful step up, not incremental. Free users should get enough to build something real and evaluate your product thoroughly. Starter should feel like the natural next step for someone who's validated their idea.
Tracking usage per account
Knowing what your free users are doing is essential. You need to track consumption in real time so you can enforce limits, identify upgrade candidates, and predict churn.
Start by creating a usage table that captures granular events. You don't necessarily need to log every single request—that gets expensive fast—but you need enough data to calculate monthly totals accurately.
interface UsageEvent {
accountId: string;
month: string; // YYYY-MM
metricType: 'api_calls' | 'storage_bytes' | 'seats';
amount: number;
timestamp: Date;
endpoint?: string; // which API endpoint or feature
}
async function recordUsage(
accountId: string,
metricType: string,
amount: number,
details?: object
): Promise {
const month = new Date().toISOString().slice(0, 7);
// Batch writes to avoid hammering the database
await usageQueue.push({
accountId,
month,
metricType,
amount,
timestamp: new Date(),
...details
});
}Use queues and batch writes to avoid database contention. Recording usage synchronously on every request is a quick way to create bottlenecks. Instead, push events into a queue (Redis, SQS, or similar), then flush them to your analytics database in batches every few seconds.
For real-time limit enforcement, maintain a cache of current month usage for active accounts. Redis is ideal here because lookups are instant and you can set keys to expire at the end of each month automatically.
async function getCurrentMonthUsage(
accountId: string,
metricType: string
): Promise {
const month = new Date().toISOString().slice(0, 7);
const cacheKey = `usage:${accountId}:${metricType}:${month}`;
let usage = await cache.get(cacheKey);
if (usage === null) {
// Cache miss, query database
usage = await db.query(
'SELECT COALESCE(SUM(amount), 0) as total FROM usage_events ' +
'WHERE accountId = $1 AND metricType = $2 AND month = $3',
[accountId, metricType, month]
);
// Cache for 5 minutes
await cache.setex(cacheKey, 300, usage);
}
return usage;
}
async function checkUsageLimit(
accountId: string,
metricType: string,
limit: number
): Promise {
const current = await getCurrentMonthUsage(accountId, metricType);
return current < limit;
}This dual-layer approach keeps your enforcement fast while your analytics pipeline can handle longer calculations. Monthly reconciliation should run once a month to fix any cache inconsistencies.
Implementing soft and hard limits
Soft limits are warnings. Hard limits are walls. Both are necessary, and they serve different purposes.
A soft limit is typically 80–90% of your actual hard limit. When a user hits a soft limit, you warn them, show them their upgrade options, and maybe throttle performance slightly (add a 100ms delay to API responses, for example). The key is that their requests still succeed. Soft limits are conversion opportunities dressed up as friendly warnings.
A hard limit is your actual ceiling. When a user hits it, you reject new requests with a 429 (Too Many Requests) status code. Hard limits protect your infrastructure and force the economic decision: upgrade or wait until next month.
async function handleApiRequest(
accountId: string,
endpoint: string
): Promise {
const account = await db.getAccount(accountId);
const plan = await getPlanForAccount(account);
const currentUsage = await getCurrentMonthUsage(accountId, 'api_calls');
// Check hard limit
if (currentUsage >= plan.apiCallsLimit) {
return new Response(
JSON.stringify({
error: 'Usage limit exceeded',
limit: plan.apiCallsLimit,
current: currentUsage,
resetDate: getNextMonthStart()
}),
{
status: 429,
headers: {
'Retry-After': '3600',
'X-RateLimit-Limit': plan.apiCallsLimit.toString(),
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': getNextMonthStart().toISOString()
}
}
);
}
// Check soft limit (80% of hard limit)
const softLimit = plan.apiCallsLimit * 0.8;
if (currentUsage >= softLimit) {
// Add headers to signal the upgrade opportunity
const response = await processRequest(endpoint);
response.headers.set('X-Usage-Warning', 'true');
response.headers.set('X-Upgrade-Required', 'true');
// Optional: add latency to encourage upgrade
if (currentUsage >= softLimit * 1.1) {
await delay(100);
}
return response;
}
return processRequest(endpoint);
}Communicate the soft limit to your frontend with response headers. Your client library can then show a banner: "You've used 87% of your monthly API calls. Upgrade to Pro for unlimited calls."
For seat-based products (like team collaboration tools), hard limits make sense immediately—if you're on a free plan with 2 seats and you try to add a 3rd, reject it. For consumption-based limits (API calls, storage), giving users a grace period of a few hours after hitting soft limits is more user-friendly and often leads to conversions.
Designing upgrade prompts at the right moment
Timing is everything with upgrade prompts. Too early and you're annoying users who haven't even evaluated your product. Too late and they've already churned or found an alternative.
The soft limit itself is your first prompt. It's automated, non-intrusive, and works because it appears when users are actually trying to do something valuable—not at random intervals or on a landing page they'll ignore.
Build a second layer of prompts triggered by specific user behaviors:
- Usage milestone prompts: "You've made 100K API calls this month. Upgrade to remove monthly limits."
- Feature trial prompts: "Advanced analytics requires the Pro plan. Start a free trial?"
- Inactivity re-engagement: "We noticed you haven't used your free tier in 30 days. Thinking of building something? Here's what's new."
Always tie your upgrade prompts to clear value propositions. Don't say "Upgrade now." Say "Upgrade to Pro for unlimited API calls and 99.9% SLA."
async function evaluateUpgradePrompt(
accountId: string
): Promise {
const account = await db.getAccount(accountId);
if (account.planId !== 'free') return null;
const thisMonth = new Date().toISOString().slice(0, 7);
const apiUsage = await getCurrentMonthUsage(accountId, 'api_calls');
const plan = await getPlanForAccount(account);
// Soft limit + 10% usage
if (apiUsage >= plan.apiCallsLimit * 0.9) {
return {
type: 'soft_limit',
headline: 'You are approaching your API call limit',
cta: 'Upgrade to Pro',
tier: 'pro',
dismissed: false
};
}
// Check if user is a power user (using 70%+ every month for 3 months)
const last3Months = await getUsageTrend(accountId, 'api_calls', 3);
if (last3Months.every(m => m >= plan.apiCallsLimit * 0.7)) {
return {
type: 'power_user',
headline: 'You consistently max out your free tier',
cta: 'Explore Pro pricing',
tier: 'pro',
dismissed: false
};
}
return null;
}In-app prompts work better than email for conversion. Show them in a sidebar, banner, or modal when users are already engaged with your product. Track whether they dismiss, click through, or ignore each prompt—this data informs your prompt strategy over time.
Common mistakes
Unclear limits: "Generous free tier" is not a limit. Users will discover it randomly and feel betrayed. Define every limit numerically.
Inconsistent enforcement: If some users hit the hard limit and others don't, you have a problem. Test your limit enforcement in staging with realistic load. Bugs here will tank your conversion rate.
Prompts everywhere: Bombarding users with upgrade prompts on every screen destroys trust. Use them sparingly, tied to real moments when users would benefit from upgrading.
Ignoring the free tier as a funnel stage: Your free plan isn't a feature—it's part of your acquisition funnel. Track cohorts of free users: how many convert, when, which features do converters use, how long does it take? This data shapes your limits and prompts.
Not measuring cache hit rates: If your usage cache is misconfigured, you'll either over-enforce (false positives) or under-enforce (false negatives). Monitor cache hit rates and reconciliation errors daily.
Key takeaways
- Define limits based on your actual cost structure, not arbitrary numbers. Communicate them clearly everywhere users appear.
- Track usage in batches for scalability, but cache current-month totals in Redis for fast enforcement.
- Soft limits warn and convert; hard limits protect your infrastructure. Combine both for a sustainable freemium model.
- Time upgrade prompts to moments of genuine value—especially soft limits—not random page views.
- Free plans are acquisition funnels. Measure cohorts, track behavior, and iterate on limits based on conversion data.
- Test enforcement thoroughly. Bugs in limit checking will silently destroy your unit economics.






Comments
Loading comments…