My side project's free tiers were dying, so I counted what each service actually bills A developer behind the movie discovery side project Galaxy Movies audited why its Vercel, Upstash Redis, and OpenAI free tiers were being exhausted, finding that bulk AI scrapers and SEO crawlers were triggering full server-side renders and that Upstash bills each pipeline member as a separate command. The fixes included serving a thin render path to non-indexing bots, moving robots.txt to a static file with a Disallow list for crawlers like GPTBot and ClaudeBot, lengthening CDN cache windows, and collapsing rate limiting into a single Lua script billed once per EVAL. Galaxy Movies is a movie discovery side project ~ Next.js Pages Router on Vercel, TMDB for data, Upstash Redis for caching & rate limiting, and OpenAI for a chat assistant & generated synopses. Everything runs on free tiers, which is the whole point of a side project. A few weeks ago all three of those free tiers started groaning at once. Vercel function usage was climbing, Upstash was burning through its daily command quota well before midnight, and OpenAI spend had a suspicious correlation with traffic spikes that produced no actual readers. The fixes all came down to the same realization: every "free" tier bills a different unit, and I was counting the wrong things. Here's what was actually billing me, and what I changed. Vercel bills function duration , and my movie and TV detail pages were expensive: five parallel TMDB fetches title, watch providers, credits, ratings, recommendations , plus Redis reads for the AI content slots, all in getServerSideProps . The expensive traffic wasn't humans. SEO crawlers and AI scrapers sweep the entire TMDB ID space in bulk, and every one of those detail pages ran the full fetch chain. They don't index anything and send zero readers. Pure cost. The fix was a thin render path just for them: // Bots that don't index get a minimal render. They sweep the TMDB id // space in bulk and don't need providers, credits, videos, or AI slots. if isBot ua && isSearchEngine ua { const titleRes = await fetch https://api.themoviedb.org/3/${media}/${id}?api key=${apiKey} // on failure return notFound or errorProps; otherwise: context.res.setHeader 'Cache-Control', 'private, no-store' return { props: { ...thinProps, media, title, topCast: } } } Two details matter here: isSearchEngine split. isBot matches a broad allowlist, but Googlebot, Bingbot, and DuckDuckGo still get the full render since they index. Social preview bots get the thin version. Whether a bot indexes is the only question that matters. private, no-store is critical. I also found robots.txt was being rendered by a Next.js page , which meant every crawler's very first request was a function invocation. It moved to a static public/robots.txt , which doubles as an enforcement layer: User-agent: AhrefsBot User-agent: SemrushBot User-agent: MJ12bot User-agent: Bytespider User-agent: GPTBot User-agent: ClaudeBot User-agent: CCBot ...several more Disallow: / Yes, Disallow is an honor system, but the big commercial crawlers honor it, and they were most of the volume. Finally, I lengthened the SSR CDN cache windows: s-maxage from 1 hour to 1 day, stale-while-revalidate from 1 day to 1 week. Repeat crawler hits on popular titles now terminate at the edge instead of running SSR again. This one caught me off guard. Upstash's free tier bills per command , and a pipeline doesn't save you, because each member of a pipeline is its own billed command . My request paths fanned out into Redis calls that felt free because "Redis is fast." The audit turned up some embarrassing finds. Cache reads on detail pages were ~97% misses, so I was paying a billed command for a null almost every time. Rate limiting cost up to three commands per check. A dataset updated once a day was being fetched again on every pageview. The fixes, roughly in order of satisfaction: Rate limiting became one Lua script. INCRBY plus a conditional EXPIRE plus a TTL recovery check run inside a single EVAL , billed once no matter how many redis.call s it makes: local count = redis.call "INCRBY", KEYS 1 , ARGV 1 if count <= tonumber ARGV 1 then redis.call "EXPIRE", KEYS 1 , ARGV 2 elseif count tonumber ARGV 3 and redis.call "TTL", KEYS 1 < 0 then redis.call "EXPIRE", KEYS 1 , ARGV 2 end return count The TTL check handles a real edge case: if a key somehow loses its expiry, a blocked client fixes it on the next attempt instead of being rate limited forever. Check caches before paying the rate limit write. /api/prices was rate limiting requests that would have been cache hits, paying a write before doing a read. Reorder, and every cache hit is one command instead of two. Memoize what changes daily. The streaming-changes diffs are rewritten once a day by cron but were being fetched per key on every pageview. A per instance LRU holding results for ten minutes turns repeat page loads into zero Redis commands: js const changesMemo = new LRUCache