●R8 — From Expo SDK 58, R8 is enabled by default for Android release builds. Measuring the same code before and after shows both what shrank and what broke●XCODE26.6 — The Xcode 27 image for EAS Build is still coming soon, and latest is still Xcode 26.6. Which bugs you hit depends on whether your local Xcode is already on 27●11/01 — Extension requests for the Google Play target API level close on November 1, forty days away. New and updated apps need API 36 or higher, existing apps API 35 or higher●FETCH — expo/fetch is reported to hang without settling on iOS, while Android resolves a truncated body as a plain 200. Nothing throws, so the timeout has to be yours●NEW — A build that stops at exit code 0: the flag that silenced the logs, and the check that let an empty file through●SCENE — expo@57.0.23 added an opt-in for launching under Xcode 27 while staying on SDK 57. Enable ios.enableSceneSupport through expo-build-properties●R8 — From Expo SDK 58, R8 is enabled by default for Android release builds. Measuring the same code before and after shows both what shrank and what broke●XCODE26.6 — The Xcode 27 image for EAS Build is still coming soon, and latest is still Xcode 26.6. Which bugs you hit depends on whether your local Xcode is already on 27●11/01 — Extension requests for the Google Play target API level close on November 1, forty days away. New and updated apps need API 36 or higher, existing apps API 35 or higher●FETCH — expo/fetch is reported to hang without settling on iOS, while Android resolves a truncated body as a plain 200. Nothing throws, so the timeout has to be yours●NEW — A build that stops at exit code 0: the flag that silenced the logs, and the check that let an empty file through●SCENE — expo@57.0.23 added an opt-in for launching under Xcode 27 while staying on SDK 57. Enable ios.enableSceneSupport through expo-build-properties
How I Cut My Rork App's AI Costs from $350 to $35/Month with Cloudflare AI Gateway
The default AI Gateway cache key is built from the request you actually send. Here is why prompt normalization silently does nothing, how to wire it in with cf-aig-cache-key, and the Workers and React Native code I run in production.
Three months after releasing my third AI-powered app on the App Store, I opened my OpenAI billing dashboard and saw a charge that made me pause: $352 for the month.
My app had around 800 active users. That works out to roughly $0.44 per user per month in AI costs — before accounting for hosting, Apple's 30% cut, and my own time. The subscription I was charging, $4.99/month, barely covered the AI spend for a single heavy user.
The root cause, once I actually dug into it, was embarrassingly simple: the same prompts were hitting the API hundreds of times per day. Category classification requests, text summarization, content tagging — computationally identical across different users, billed separately every single time. When user A asks to classify "productivity apps for remote workers" and user B asks the exact same thing two minutes later, both requests go to OpenAI, both get billed, both return the same answer.
The fix I eventually landed on was Cloudflare AI Gateway. Three weeks after deployment, the same traffic volume cost $33.
The Economics Problem with AI Features in Indie Apps
Before getting into implementation, it's worth being explicit about why this matters structurally.
When you add AI features to a Rork app, you're essentially making your cost structure variable in a way that traditional features aren't. A push notification infrastructure costs roughly the same whether you have 100 or 10,000 users. AI API costs scale linearly with usage — and in some architectures, superlinearly if you're not careful.
The table below shows how this plays out at different user counts, assuming $0.44/user/month in unoptimized AI costs and a $4.99/month subscription:
100 users: AI costs $44, revenue $499, margin $455
1,000 users: AI costs $440, revenue $4,990, margin $4,550
10,000 users: AI costs $4,400, revenue $49,900, margin $45,500
50,000 users: AI costs $22,000, revenue $249,500, margin $227,500
At small scale it looks fine. But the margin percentage never actually improves — AI cost stays at roughly 9% of revenue no matter how big you grow. And that's assuming perfect subscription conversion. With a realistic 5-8% conversion rate from free to paid, the math inverts quickly.
Caching changes this fundamentally. Once you have enough users that popular prompts repeat frequently, the marginal AI cost of new users starts declining. With a 70% cache hit rate, your AI cost at 50,000 users isn't $22,000 — it's closer to $6,600. That's a business model that actually improves with scale.
What Cloudflare AI Gateway Actually Is
Cloudflare AI Gateway is an intelligent proxy layer that sits between your application and AI providers like OpenAI, Anthropic, and Google Gemini. You route requests through it rather than hitting providers directly, and it gives you several capabilities that are otherwise expensive or complex to build yourself:
Response caching: When two requests contain identical prompts (after normalization), the second request returns the cached result — no API call, no token cost, sub-100ms response time.
Multi-provider support: A single unified interface routes to OpenAI, Anthropic, Gemini, Groq, Mistral, Cohere, and others. Switching providers or adding fallbacks becomes a configuration change rather than a code change.
Automatic failover: You can configure ordered provider lists so that if OpenAI returns a 5xx or rate limit error, the request automatically retries with Gemini or another fallback.
Rate limiting: Set caps on requests per minute, per user, or globally. Essential for preventing a runaway script or a single heavy user from spiking your bill.
Detailed analytics: The dashboard shows request volume, cache hit rates, error rates by provider, latency percentiles, and token usage — all in real time.
The pricing is what makes this genuinely useful for solo developers: the free tier supports 100,000 requests per day. For most indie apps, you'll never pay for the gateway itself. The savings come entirely from reduced API provider costs.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦The five components of the default cache key, and why normalizing a prompt does nothing unless it reaches the request or cf-aig-cache-key
✦A production Workers proxy with typed OpenAI-to-Gemini failover, gateway-side timeouts and retries, and cf-aig-cache-status passed through to the client
✦The cache-bypass header people get wrong (cf-aig-cache-ttl: 0 is not it), the 60-second TTL floor, and a device-side cache key that does not collide
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The deployment pattern adds a Cloudflare Workers proxy between your Rork app and the AI providers:
[Rork App (React Native)]
↓ HTTPS
[Cloudflare Workers — your proxy]
↓ AI Gateway URL
[Cloudflare AI Gateway — caching, routing, rate limiting]
↓
[OpenAI / Gemini / Anthropic]
Why not call AI Gateway directly from the app?
Two reasons: security and flexibility. Embedding API keys in a mobile binary is a security risk — they can be extracted from the compiled app and used against your account. And you need server-side logic anyway: cache key normalization, user authentication, rate limit state management, and the provider fallback logic all need to run in a trusted environment.
Cloudflare Workers is the ideal host for this proxy layer. The free tier covers 100,000 daily requests, Workers instances cold-start in under 5ms globally, and you can use Workers KV for rate limit counters without any additional infrastructure.
Step 1: Gateway Setup in Cloudflare Dashboard
In your Cloudflare dashboard, go to AI → AI Gateway and click "Create Gateway." Name it something like rork-app-production and note the Account ID and Gateway ID from the detail page.
You'll get provider-specific endpoint URLs:
# OpenAI
https://gateway.ai.cloudflare.com/v1/{ACCOUNT_ID}/{GATEWAY_ID}/openai
# Google Gemini
https://gateway.ai.cloudflare.com/v1/{ACCOUNT_ID}/{GATEWAY_ID}/google-ai-studio
# Anthropic
https://gateway.ai.cloudflare.com/v1/{ACCOUNT_ID}/{GATEWAY_ID}/anthropic
# Groq (fast inference, useful for latency-sensitive tasks)
https://gateway.ai.cloudflare.com/v1/{ACCOUNT_ID}/{GATEWAY_ID}/groq
In the gateway settings, enable "Cache Responses" and set a default TTL. I use 24 hours for classification tasks. Caching is off until you do this, which is worth saying plainly: I swapped the URL one evening, came back the next morning expecting a hit rate, and spent a while staring at 0% before I found the toggle.
You can override the default per request with headers, which is what the Workers code below does.
What the Cache Key Is Actually Made Of
Before the code, the thing I wish I had read first.
By default the gateway builds the cache key by concatenating five things and hashing the result with SHA-256, as described in the AI Gateway caching documentation:
Component
What it means in practice
Provider (openai, anthropic, …)
Nothing is shared across providers
Endpoint (the API path)
A different path is a different cache
Model
Swap the model and you start from an empty cache
Provider auth header
Rotate your API key and the cache turns over with it
Full request body
One character of difference in messages, tools, or parameters is a separate entry
Two consequences follow, and both cost me time.
The first: a normalization function only helps if the normalized string is what you actually send, or if you override the key yourself with cf-aig-cache-key. Writing the function and leaving the original prompt in the body changes nothing. The proxy below sends normalizedPrompt, and also sets an explicit key so the behaviour does not depend on a detail someone might refactor away later.
The key is decided by the request that leaves your Worker, not by the helper you wrote next to it.
The second: because the auth header is part of the key, a cost spike the day after a key rotation may be nothing more than a cold cache. That is a much calmer thing to discover on purpose than at two in the morning.
Step 2: The Workers Proxy Implementation
This is the complete Workers code. It's longer than a minimal example because it handles everything you'll actually need in production: authentication, per-user rate limiting, prompt normalization, cache header injection, and provider failover.
// workers/ai-proxy/src/index.tsimport { Hono } from 'hono'import { cors } from 'hono/cors'type Bindings = { OPENAI_API_KEY: string GEMINI_API_KEY: string AI_GATEWAY_ACCOUNT_ID: string AI_GATEWAY_ID: string RATE_LIMIT: KVNamespace}type Variables = { userId: string}const app = new Hono<{ Bindings: Bindings; Variables: Variables }>()// CORS — lock this down to your app's actual domain in productionapp.use('*', cors({ origin: (origin) => { const allowed = ['https://your-app-domain.com', 'exp://localhost'] return allowed.includes(origin) ? origin : null }, allowHeaders: ['Content-Type', 'Authorization', 'X-User-ID'],}))// Auth middlewareapp.use('/ai/*', async (c, next) => { const userId = c.req.header('X-User-ID') if (!userId) { return c.json({ error: 'Missing X-User-ID header' }, 401) } c.set('userId', userId) await next()})// Main text generation endpointapp.post('/ai/generate', async (c) => { const userId = c.get('userId') const body = await c.req.json<{ prompt: string model?: string cache?: boolean maxTokens?: number }>() const { prompt, model = 'gpt-4o-mini', cache = true, maxTokens = 1000, } = body // Rate limiting: 50 requests per user per hour const hourSlot = Math.floor(Date.now() / 3600000) const rateLimitKey = `rl:${userId}:${hourSlot}` const currentCount = parseInt(await c.env.RATE_LIMIT.get(rateLimitKey) || '0') if (currentCount >= 50) { const msUntilReset = 3600000 - (Date.now() % 3600000) return c.json({ error: 'Rate limit exceeded', retryAfterSeconds: Math.ceil(msUntilReset / 1000), }, 429) } // Increment with TTL slightly longer than the hour slot to handle edge cases await c.env.RATE_LIMIT.put(rateLimitKey, String(currentCount + 1), { expirationTtl: 7200, // 2 hours }) const normalizedPrompt = normalizePrompt(prompt) const cacheEnabled = cache && isCacheable(normalizedPrompt) const cacheKey = buildCacheKey(normalizedPrompt, model, 'generate') const gatewayBase = `https://gateway.ai.cloudflare.com/v1/${c.env.AI_GATEWAY_ACCOUNT_ID}/${c.env.AI_GATEWAY_ID}` // Attempt OpenAI first const openAIResult = await tryOpenAI({ prompt: normalizedPrompt, model, maxTokens, cacheEnabled, cacheKey, gatewayBase, apiKey: c.env.OPENAI_API_KEY, }) if (openAIResult.success) { // Pass HIT/MISS through so the client can log why a call felt slow return c.json(openAIResult.data, 200, { 'x-ai-cache-status': openAIResult.cacheStatus, 'x-ai-provider': 'openai', }) } // Log the failure reason before attempting fallback console.warn(`OpenAI failed (${openAIResult.errorCode}), trying Gemini fallback`) // Only fall back when it makes sense to try a different provider if (!shouldFallback(openAIResult.errorCode)) { return c.json({ error: openAIResult.errorMessage }, openAIResult.errorCode) } const geminiResult = await tryGemini({ prompt: normalizedPrompt, cacheEnabled, cacheKey, gatewayBase, apiKey: c.env.GEMINI_API_KEY, }) if (geminiResult.success) { return c.json(geminiResult.data, 200, { 'x-ai-cache-status': geminiResult.cacheStatus, 'x-ai-provider': 'gemini', }) } console.error(`All providers failed. OpenAI: ${openAIResult.errorCode}, Gemini: ${geminiResult.errorCode}`) return c.json({ error: 'AI service temporarily unavailable' }, 503)})// Structured OpenAI call with explicit success/failure typingasync function tryOpenAI(params: { prompt: string model: string maxTokens: number cacheEnabled: boolean cacheKey: string gatewayBase: string apiKey: string}): Promise< | { success: true; data: unknown; cacheStatus: string } | { success: false; errorCode: number; errorMessage: string }> { const headers: Record<string, string> = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${params.apiKey}`, // Time to the first byte of the response, enforced by the gateway 'cf-aig-request-timeout': '8000', // Transient failures are retried before they reach this code 'cf-aig-max-attempts': '3', 'cf-aig-retry-delay': '500', 'cf-aig-backoff': 'exponential', } if (params.cacheEnabled) { // Setting the key opts this request into caching and pins it to the // normalized prompt, independent of the rest of the body headers['cf-aig-cache-key'] = params.cacheKey headers['cf-aig-cache-ttl'] = '86400' } else { // Note: cf-aig-cache-ttl: '0' does NOT disable caching. This does. headers['cf-aig-skip-cache'] = 'true' } try { const response = await fetch(`${params.gatewayBase}/openai/chat/completions`, { method: 'POST', headers, body: JSON.stringify({ model: params.model, messages: [{ role: 'user', content: params.prompt }], max_tokens: params.maxTokens, temperature: params.cacheEnabled ? 0 : 0.7, }), }) if (response.ok) { return { success: true, data: await response.json(), cacheStatus: response.headers.get('cf-aig-cache-status') ?? 'UNKNOWN', } } let errorMessage = `OpenAI error ${response.status}` try { const errBody: any = await response.json() errorMessage = errBody?.error?.message || errorMessage } catch { /* ignore */ } return { success: false, errorCode: response.status, errorMessage } } catch (err: any) { if (err.name === 'TimeoutError') { return { success: false, errorCode: 504, errorMessage: 'OpenAI request timed out' } } return { success: false, errorCode: 503, errorMessage: 'OpenAI network error' } }}// Gemini call with OpenAI-compatible response formatasync function tryGemini(params: { prompt: string cacheEnabled: boolean cacheKey: string gatewayBase: string apiKey: string}): Promise< | { success: true; data: unknown; cacheStatus: string } | { success: false; errorCode: number; errorMessage: string }> { const headers: Record<string, string> = { 'Content-Type': 'application/json', 'x-goog-api-key': params.apiKey, 'cf-aig-request-timeout': '12000', } if (params.cacheEnabled) { // Prefix the key: the provider is part of the default key, so once you // override it you have to keep providers apart yourself headers['cf-aig-cache-key'] = `gemini:${params.cacheKey}` headers['cf-aig-cache-ttl'] = '86400' } else { headers['cf-aig-skip-cache'] = 'true' } try { const response = await fetch( `${params.gatewayBase}/google-ai-studio/v1beta/models/gemini-2.0-flash:generateContent`, { method: 'POST', headers, body: JSON.stringify({ contents: [{ parts: [{ text: params.prompt }] }], generationConfig: { temperature: params.cacheEnabled ? 0 : 0.7 }, }), } ) if (!response.ok) { return { success: false, errorCode: response.status, errorMessage: `Gemini error ${response.status}`, } } const data: any = await response.json() const content = data?.candidates?.[0]?.content?.parts?.[0]?.text if (!content) { return { success: false, errorCode: 500, errorMessage: 'Empty Gemini response' } } // Return in OpenAI-compatible format so the Rork app doesn't need provider-specific handling return { success: true, data: { choices: [{ message: { role: 'assistant', content }, finish_reason: 'stop' }], _provider: 'gemini', }, cacheStatus: response.headers.get('cf-aig-cache-status') ?? 'UNKNOWN', } } catch (err: any) { if (err.name === 'TimeoutError') { return { success: false, errorCode: 504, errorMessage: 'Gemini request timed out' } } return { success: false, errorCode: 503, errorMessage: 'Gemini network error' } }}// Whether a given HTTP error code warrants trying a different providerfunction shouldFallback(errorCode: number): boolean { // 400: Bad request — the prompt itself is malformed. Gemini won't help. if (errorCode === 400) return false // 401: Auth error — a key configuration problem, not provider-specific. if (errorCode === 401) return false // 403: Permissions — same as 401. if (errorCode === 403) return false // 429: Rate limited — a different provider might have quota remaining. if (errorCode === 429) return true // 5xx: Server errors — definitely worth trying another provider. if (errorCode >= 500) return true // 504: Timeout — worth trying. if (errorCode === 504) return true return false}// Normalize prompt text to maximize cache hit ratefunction normalizePrompt(prompt: string): string { return prompt .trim() .normalize('NFKC') .replace(/\s+/g, ' ') .replace(/[""]/g, '"') .replace(/['']/g, "'") .replace(/ /g, ' ') // Japanese full-width space .toLowerCase()}// FNV-1a: no dependencies, and it produces the same value in Workers// and in React Native. Share this file between both sides.export function fnv1a(input: string): string { let hash = 0x811c9dc5 for (let i = 0; i < input.length; i++) { hash ^= input.charCodeAt(i) hash = Math.imul(hash, 0x01000193) >>> 0 } return hash.toString(16).padStart(8, '0')}// The model is part of the key so a model swap never serves stale answersexport function buildCacheKey(normalized: string, model: string, task: string): string { return `${task}:${model}:${fnv1a(normalized)}`}// Conservative check: only cache when we're confident the output is deterministic enoughfunction isCacheable(prompt: string): boolean { // Personal context signals — don't cache const personalSignals = /\b(my|mine|i am|i'm|our|we|yesterday|today|right now|currently|this week)\b/i if (personalSignals.test(prompt)) return false // Too short — context too ambiguous if (prompt.length < 40) return false // Classification, summarization, and extraction are great candidates const cacheHints = /(classify|categorize|summarize|translate|extract|tag|label|detect|identify)/i if (cacheHints.test(prompt)) return true // Default: cache it. Conservative analysis above should catch problematic cases. return true}export default app
Step 3: The Rork App Integration
// hooks/useAI.ts — Drop this into your Rork-generated appimport { useState, useCallback, useRef } from 'react'import AsyncStorage from '@react-native-async-storage/async-storage'const WORKER_URL = process.env.EXPO_PUBLIC_AI_WORKER_URL!const LOCAL_CACHE_TTL_MS = 60 * 60 * 1000 // 1 hourinterface GenerateOptions { prompt: string model?: string cache?: boolean}interface GenerateResult { content: string fromLocalCache: boolean cacheStatus: string // HIT or MISS at the gateway provider: string}// Same file as the Worker uses. If the two drift, the two tiers// start caching under different keys and neither one looks broken.import { buildCacheKey, normalizePrompt } from '../shared/cacheKey'export function useAI() { const [loading, setLoading] = useState(false) const [error, setError] = useState<string | null>(null) const controllerRef = useRef<AbortController | null>(null) const generate = useCallback(async (opts: GenerateOptions): Promise<GenerateResult | null> => { const { prompt, model = 'gpt-4o-mini', cache = true } = opts setLoading(true) setError(null) controllerRef.current?.abort() controllerRef.current = new AbortController() // Tier 1: AsyncStorage local cache (instant, no network) if (cache) { try { const key = `aicache:${buildCacheKey(normalizePrompt(prompt), model, 'generate')}` const raw = await AsyncStorage.getItem(key) if (raw) { const { data, ts } = JSON.parse(raw) if (Date.now() - ts < LOCAL_CACHE_TTL_MS) { setLoading(false) return { ...data, fromLocalCache: true } } } } catch { // Non-fatal, continue to network request } } try { const userId = await resolveUserId() const res = await fetch(`${WORKER_URL}/ai/generate`, { method: 'POST', signal: controllerRef.current.signal, headers: { 'Content-Type': 'application/json', 'X-User-ID': userId, }, body: JSON.stringify({ prompt, model, cache }), }) if (res.status === 429) { const { retryAfterSeconds } = await res.json() const minutes = Math.ceil(retryAfterSeconds / 60) setError(`Request limit reached. Try again in ${minutes} minute${minutes !== 1 ? 's' : ''}.`) setLoading(false) return null } if (res.status === 503) { setError('AI is temporarily unavailable. Please try again shortly.') setLoading(false) return null } if (!res.ok) { throw new Error(`Worker returned ${res.status}`) } const json = await res.json<{ choices?: Array<{ message?: { content?: string } }>; _provider?: string }>() const content = json.choices?.[0]?.message?.content if (typeof content !== 'string') { throw new Error('AI response contained no content') } const result: GenerateResult = { content, fromLocalCache: false, cacheStatus: res.headers.get('x-ai-cache-status') ?? 'UNKNOWN', provider: res.headers.get('x-ai-provider') ?? json._provider ?? 'openai', } // Tier 2 write: save to local cache for subsequent requests if (cache) { const key = `aicache:${buildCacheKey(normalizePrompt(prompt), model, 'generate')}` AsyncStorage.setItem(key, JSON.stringify({ data: result, ts: Date.now() })).catch(() => {}) } setLoading(false) return result } catch (err: any) { if (err.name === 'AbortError') { setLoading(false) return null } setError('Something went wrong. Please try again.') console.error('[useAI] error:', err) setLoading(false) return null } }, []) const cancel = useCallback(() => controllerRef.current?.abort(), []) return { generate, loading, error, cancel }}async function resolveUserId(): Promise<string> { const existing = await AsyncStorage.getItem('ai_user_id') if (existing) return existing const generated = `anon:${Date.now().toString(36)}${Math.random().toString(36).slice(2)}` await AsyncStorage.setItem('ai_user_id', generated) return generated}
Usage in a component:
// Example: AI-powered content categorization in a Rork appimport { useAI } from '../hooks/useAI'export function CategoryTagger({ content }: { content: string }) { const { generate, loading, error } = useAI() const [category, setCategory] = useState<string | null>(null) const categorize = async () => { const result = await generate({ prompt: `Categorize the following content into exactly one of these categories: Technology, Health, Finance, Entertainment, Education, Other. Respond with only the category name. Content: ${content}`, cache: true, // Identical content will be served from cache }) if (result) { setCategory(result.content.trim()) // Optional: show a subtle indicator if served from local cache if (result.fromLocalCache) { console.log('Category served from local cache — instant response') } } } return ( // ... your UI )}
The Three Cache-Killing Mistakes
Mistake 1: Streaming and caching are mutually exclusive
Cloudflare AI Gateway cannot cache streamed responses. This is a hard architectural constraint.
If you have chat-like features that need real-time streaming output, keep them on a separate endpoint (/ai/stream) that sets cf-aig-skip-cache: true. Reserve the cacheable /ai/generate endpoint for batch-style tasks like classification, summarization, and extraction.
One correction I owe anyone who read an earlier version of this: cf-aig-cache-ttl: 0 is not a way to disable caching. That header sets the duration of a request that is already being cached, and the accepted range runs from 60 seconds to one month. The header that bypasses the cache is cf-aig-skip-cache. I had the wrong one in production for a while, and the reason it never announced itself is that caching was off at the gateway level anyway — so the code looked correct right up until the day I turned caching on.
Mistake 2: Variable temperature breaks cache key consistency
If you call the same prompt with temperature: 0.7 and then with temperature: 0.8, these are different cache keys even though the inputs are identical. For any task where you want caching to work, fix the temperature at 0.
This also happens to be correct behavior: classification and summarization tasks should be deterministic. Using temperature 0 isn't just a caching optimization — it produces more consistent, reliable outputs for structured tasks.
Mistake 3: Failing to normalize prompts before hashing
Consider these three prompts that should produce identical results:
"Classify the following text into a category:\nAI tools for developers"
"Classify the following text into a category: AI tools for developers"
"classify the following text into a category: AI tools for developers"
Without normalization, these are three separate cache entries. With the normalization function in the Workers code above — collapsing whitespace, lowercasing, normalizing punctuation — they become one. In my implementation that change moved cache hit rate from 12% to 61%.
The part that is easy to miss: normalizing is only half of it. The normalized string has to be the one that leaves your Worker, or it has to reach the gateway as cf-aig-cache-key. I spent a fortnight watching a flat 12% because my normalizer ran, returned a tidy string, and then the original prompt went out in the body regardless. A helper function that nothing downstream reads is indistinguishable from no helper at all.
Real Numbers After 30 Days
Here's what the Cloudflare AI Gateway dashboard showed after one month of running this setup on an app with roughly 1,200 monthly active users:
Total requests served: 47,800
Cache hits: 35,100 (73.4%)
Actual API calls made: 12,700
Gemini fallback activations: 340 (0.71% of requests)
Average response latency (cache hit): 87ms
Average response latency (API call): 1,240ms
OpenAI API cost: $18.40
Previous month (no caching): $127.60
The latency improvement was an unexpected bonus. Cache hits returning in under 100ms versus 1-2 seconds for live API calls made the app feel noticeably snappier — user session lengths increased by about 18% in the week after deployment.
Two notes on reading these numbers. The dashboard is for trends; for any single request, cf-aig-cache-status comes back as HIT or MISS, which is why the proxy above forwards it to the client. When someone reports that the app felt slow, knowing whether that one call was a MISS narrows the search considerably.
And 73.4% is the hit rate for this app's prompt mix — mostly classification and summarization. An app built around open-ended conversation will not land anywhere near it. I would rather you watched your own Cache Hit Rate for a week than trusted my number. The gateway cache is also volatile: the documentation notes that two identical requests arriving at once may both miss, because the first has not finished storing when the second arrives. Cache warming shortens the queue at the front; it does not eliminate it.
Where to Start
The implementation in this article is production-ready, but deploy incrementally:
Week 1: Set up AI Gateway in Cloudflare and point your existing Workers code at the gateway URL. Caching is off by default, so nothing about your behaviour changes — you simply start seeing your baseline traffic in the dashboard. Log cf-aig-cache-status from day one, even while every response is a MISS.
Week 2: Enable caching on low-risk endpoints first — classification tasks with temperature 0. Check cache hit rates after 48 hours.
Week 3: Add prompt normalization. This typically delivers the biggest cache hit rate improvement.
Week 4: Implement provider failover and rate limiting.
By the time you've completed all four weeks, you'll have a measurably cheaper and more resilient AI backend — and you'll understand exactly which parts of your app are driving the most API cost.
Advanced Caching Patterns for Common Rork App Scenarios
Beyond basic caching, there are several patterns worth knowing for specific use cases that appear frequently in Rork-generated apps.
Pattern 1: Tiered Cache TTL Based on Content Volatility
Not all prompts should be cached for the same duration. A classification of "what category does this product fall into?" is valid for weeks. A summary of "what are trending topics in this app today?" might be stale in an hour.
function getCacheTTL(prompt: string): number { // Real-time signals: very short cache if (/(trending|popular right now|latest|breaking|current)/i.test(prompt)) { return 3600 // 1 hour } // User-generated content classification: medium cache if (/(classify|categorize|tag|label)/i.test(prompt)) { return 604800 // 1 week } // Factual extraction from static content: long cache if (/(extract|summarize|translate)/i.test(prompt)) { return 2592000 // 30 days } // Default return 86400 // 24 hours}// Apply in your fetch call:// 'cf-aig-cache-ttl': String(getCacheTTL(normalizedPrompt))
The 30-day TTL for factual extraction might seem aggressive, but if you are extracting structured data from static articles or product descriptions, the content genuinely does not change. A 30-day cache on those requests can yield dramatic cost reductions on apps with large content libraries.
Pattern 2: Cache Warming for Predictable High-Traffic Prompts
In apps with user-generated content, certain prompts become predictably popular. A news aggregation app will classify the same viral article hundreds of times in an hour. Instead of letting the first user trigger the API call while the next few hundred also hit uncached responses (there is a brief population window), you can proactively warm the cache when content is ingested:
export async function warmCacheForContent( contentText: string, gatewayBase: string, apiKey: string): Promise<void> { const snippet = contentText.slice(0, 500).toLowerCase().trim().replace(/\s+/g, ' ') const warmupPrompts = [ `classify this content into one of: technology, health, finance, entertainment, education, other. content: ${snippet}`, `extract the top 3 keywords from this content: ${snippet}`, `write a one-sentence summary of this content: ${snippet}`, ] // Fire all warmup requests in parallel — they populate the cache before real users arrive await Promise.allSettled( warmupPrompts.map(prompt => fetch(`${gatewayBase}/openai/chat/completions`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'cf-aig-cache-ttl': '86400', }, body: JSON.stringify({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: prompt }], temperature: 0, max_tokens: 200, }), }) ) )}
Run this as part of your content ingestion pipeline. By the time users request new content, the cache is already warm — zero latency penalty for the first batch of readers.
Pattern 3: Selective Cache Bypass for Premium Users
Some users will notice and care if they receive a cached response that feels slightly stale for their specific context. A practical solution is giving premium users a way to bypass cache while keeping it active for free-tier users:
app.post('/ai/generate', async (c) => { const userId = c.get('userId') const body = await c.req.json() const { prompt, cache = true, forceRefresh = false } = body // Premium users can request fresh results by passing forceRefresh: true // This is enforced server-side so free users cannot bypass cache const isPremiumUser = userId.startsWith('premium:') const bypassCache = forceRefresh && isPremiumUser const cacheEnabled = cache && !bypassCache && isCacheable(normalizePrompt(prompt)) // ... rest of implementation // In headers: 'cf-aig-cache-ttl': cacheEnabled ? '86400' : '0'})
The UX framing works in your favor here: "Premium members always get real-time AI responses" is a legitimate feature differentiator that costs you very little since most prompts will have been seen before and the cache will be fresh anyway.
Deployment Checklist
Before shipping to production, verify these items:
Workers environment variables set via wrangler secret put: OPENAI_API_KEY, GEMINI_API_KEY, AI_GATEWAY_ACCOUNT_ID, AI_GATEWAY_ID
KV namespace created and bound as RATE_LIMIT in wrangler.toml
CORS origin list locked to your actual app domain rather than a wildcard
AI Gateway "Cache Responses" enabled in Cloudflare dashboard with a sensible default TTL
Rate limit thresholds calibrated to your expected usage pattern — the 50 req/hour default here is conservative; adjust based on your feature set
Gemini API key active and billing enabled on Google Cloud Console
Failover test passed: temporarily swap in an invalid OpenAI key, confirm that Gemini responses arrive correctly with _provider: "gemini" in the body
Load test with 50 rapid identical requests and confirm cache hit rate spikes on the Cloudflare dashboard within a few minutes
The gateway itself is just infrastructure. What makes the difference is how thoughtfully you design the boundary between "this response should be cached" and "this response must be fresh." That judgment call, made consistently across your app's AI features, is what separates a sustainable AI-powered app from one that becomes unaffordable as it grows.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.