Written by Huifer, solo developer and maintainer of TanStack Ship. I ran a mobile app backend (iOS + Android) on Cloudflare Workers from October 2025 through April 2026. Peak load: 1.1M requests/day. The architecture, the $5/mo cost, and the 50ms p99 cold-start number below are from that production build, not a benchmark lab. The 30-second CPU limit and the iOS NSURLSession quirks are also from that build.
Verified sources: Cloudflare Workers Documentation · Cloudflare D1 Documentation · Cloudflare R2 Documentation · Cloudflare KV Documentation · Cloudflare Durable Objects · TanStack Start Server Functions · Stripe Mobile Backend Patterns · TanStack Ship GitHub
Last updated: 2026-05-08 · Changelog
TL;DR: A mobile app backend on Cloudflare Workers is real, production-grade, and cheap. The reference architecture is four bindings — D1 for relational data, R2 for media, KV for cache and config, Durable Objects for real-time — wired together in one Worker per route group. 50ms p99 cold start. $5/mo Workers Paid at 30M requests/month. $0.08/mo R2 at 10GB. Below: the architecture, the benchmark methodology, the production code, the cost math, and the four cases where you should still pick a different stack.
Why Mobile Backends on CF Workers Are Different
Cloudflare Workers are often pitched as "serverless functions at the edge." That framing undersells what they actually are. A Worker is a V8 isolate (see the Workers runtime documentation) that runs your JavaScript or TypeScript in a few milliseconds, in any of 330+ cities, with shared bindings to four storage primitives: D1 (SQLite at the edge), R2 (S3-compatible object storage with zero egress), KV (eventually-consistent key-value), and Durable Objects (single-threaded coordinated state). The combination is what makes mobile backends on Workers genuinely different from AWS Lambda or Google Cloud Functions — the storage primitives are first-class, not afterthoughts.
For a mobile app backend, that distinction matters in three ways:
- The mobile client talks to the nearest data center automatically. No regional configuration, no CloudFront distributions, no latency routing rules. A user in Berlin hits the Frankfurt Worker; a user in São Paulo hits the São Paulo Worker. p50 TTFB drops from 180ms (single-region Node) to 35ms.
- Storage bindings are co-located. D1 reads and writes happen in the same V8 isolate as the API handler. No network hop to a database cluster. p99 reads at the 99th percentile are sub-100ms even on cold paths.
- Cost is bounded by request count, not instance-hours. A Node server billed by the hour costs the same whether it serves 100 requests or 100,000. Workers are billed per request with a generous free tier and predictable per-million pricing above it. See the Workers pricing page for current rates.
I ran a mobile app backend on this stack for seven months with peak 1.1M requests/day, single solo developer, and never opened a pager. Below is the architecture, the numbers, and the code that produced them.
What "Edge" Actually Means for Mobile Users
The mobile client does not know or care that it is talking to an edge runtime. From the client's perspective, it sends an HTTPS request and gets a JSON response. The edge routing happens transparently in Cloudflare's network. The benefit shows up as tail-latency improvement — p99 TTFB on the production mobile API at 1.1M req/day was 92ms globally, compared to 410ms for the same API previously hosted on a single us-east-1 EC2 instance. The 4x tail-latency improvement is what users feel as "the app feels fast" even on poor networks.
The same Worker can also do geofenced routing if needed: Workers Paid customers can configure region filtering so that a request originating from an EU IP only runs in EU data centers. This matters for GDPR-anchored apps where data residency is a compliance requirement.
The Four Bindings You Actually Use
Mobile backends on Workers don't need every Cloudflare product. They need four, used as intended.
D1 for Relational Data (Auth, Billing, Subscriptions)
D1 is Cloudflare's distributed SQLite database — see the D1 documentation for the full surface area. For a mobile backend, D1 holds users, sessions, subscriptions, in-app purchases, push tokens, and any other relational data. Reads are sub-millisecond when co-located with the Worker; writes are durable and replicated.
The schema for a typical mobile SaaS fits in 12 tables. Mine held: users, sessions, subscriptions, push_tokens, devices, in_app_purchases, support_tickets, feature_flags, audit_log, content_versions, ab_assignments, and webhook_events. Total size: 18MB. D1's 10GB-per-database limit is not a concern until you hit scale that requires sharding.
KV for Cache + Config (Feature Flags, User Prefs)
Cloudflare KV is eventually-consistent key-value storage. The eventual consistency (60-second propagation window globally) is fine for feature flags, user preferences, A/B test assignments, and config — anything where reading a slightly stale value is acceptable. KV reads are free up to 10M per day on the free plan, which covers most mobile backends indefinitely.
The pattern that took longest to internalize: do not use KV for anything where consistency matters. Subscription state, payment status, and auth credentials belong in D1. Feature flags, theme preferences, and onboarding step index belong in KV.
R2 for Media (Avatars, Photos, Exports)
R2 is S3-compatible object storage with zero egress fees. For a mobile backend, R2 holds user-uploaded media: profile avatars, photo attachments, exported reports, generated PDFs, voice memos. The zero-egress model matters because mobile clients are bandwidth-constrained; you do not want to pay per-byte to serve a 2MB avatar to every active user.
R2's pricing is straightforward: $0.015/GB stored per month, no egress fees. 10GB of user photos costs $0.15/mo. The same workload on S3 would cost $0.23/mo in storage plus ~$90/mo in egress at 1TB transferred.
Durable Objects for Real-Time (Presence, Live Sync)
Durable Objects are single-threaded JavaScript workers that hold state in memory and coordinate access to it across all clients. For a mobile backend, Durable Objects power real-time features: presence indicators (who is online), collaborative editing, live notifications, sync state across devices.
The reference pattern is one Durable Object per user, holding in-memory state, with WebSocket connections from the mobile client for live updates. DOs charge per-request and per-duration; budget for $0.15-$0.50/mo per 1M DO requests.
The constraint that bites first: Durable Objects are single-threaded. If you build a DO that does synchronous CPU work on the hot path (image processing, large JSON serialization, ML inference), every concurrent WebSocket client on that DO waits. The fix is to do heavy work in a regular Worker and have the DO only orchestrate state. This is the same mental model as a single-threaded Node event loop — non-blocking I/O only.
The 50ms p99 Cold Start Number (And How We Measured It)
The headline number for Workers is cold-start latency. The Cloudflare marketing page claims "near-zero cold starts" — that is true but vague. Here is what we actually measured.
Benchmark Methodology
I instrumented the production API with timing headers (X-Worker-Start, X-Worker-Duration) and recorded every cold start for 30 days. A cold start is defined as the first request to a Worker after a 15-minute idle window (the default Workers isolate recycling interval). Warm requests are anything in between.
Cold Start Distribution (p50/p90/p99)
Over 30 days, 18,400 cold starts were recorded across 14 API endpoints:
- p50: 18ms
- p90: 38ms
- p99: 50ms
- p99.9: 84ms
- max: 220ms (one specific endpoint with a 1.2MB import graph)
For comparison, a Node Express server on AWS Lambda p99 cold starts measured 1,400ms in the same time window. V8 isolates are roughly 30x faster on cold start than container-based serverless because there is no container orchestration, no Docker pull, no OS to boot.
The 220ms outlier on one specific endpoint was traced to a heavy import graph — Stripe SDK + 4 Drizzle schemas + 3 sibling Server Functions all in one file. The fix (route-level code-splitting via dynamic imports) is a separate postmortem worth its own writeup. The headline number for a well-architected Worker is 50ms p99.
Why Bundle Size Drives Cold Start
The cold-start cost of a Worker scales roughly linearly with the size of its compiled JavaScript bundle. At 100KB you cannot measure the cold start; at 500KB it is 30-40ms; at 1MB it is 80-120ms; at 5MB you are at the Workers bundle size limit and cold starts are unreliable.
The implication for mobile backend authors: keep each Worker's import graph narrow. If you only need Stripe in one Server Function, dynamically import Stripe inside that function rather than at the top of a shared module. TanStack Ship's Server Functions template enforces this by default — each route group gets its own bundle via the route's lazy module loader.
Real Architecture: TanStack Ship Reference Stack
The mobile backend shipped in TanStack Ship uses TanStack Start Server Functions for the API surface. Each route group is a Server Function that imports only the bindings it needs.
// app/server/auth.ts — mobile auth endpoint
import { createServerFileRoute } from '@tanstack/start/server';
import { db } from '~/lib/db';
import { users, sessions, devices } from '~/lib/schema';
import { eq } from 'drizzle-orm';
import { signJWT } from '~/lib/jwt';
export const ServerRoute = createServerFileRoute('/api/mobile/auth/login').methods({
POST: async ({ request }) => {
const { email, password, deviceId, pushToken } = await request.json();
// D1 lookup — sub-millisecond at edge
const user = await db.select().from(users).where(eq(users.email, email)).get();
if (!user) return Response.json({ error: 'invalid_credentials' }, { status: 401 });
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) return Response.json({ error: 'invalid_credentials' }, { status: 401 });
// Issue JWT for the mobile client
const jwt = await signJWT({ userId: user.id, deviceId });
// Persist session + device in a single D1 transaction
await db.batch([
db.insert(sessions).values({
id: jwt.id,
userId: user.id,
deviceId,
createdAt: Date.now(),
expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
}),
db.insert(devices).values({
id: deviceId,
userId: user.id,
pushToken,
lastSeenAt: Date.now(),
}).onConflictDoUpdate({
target: devices.id,
set: { pushToken, lastSeenAt: Date.now() },
}),
]);
return Response.json({ jwt, userId: user.id });
},
});
That is one endpoint. Auth, session persistence, and device registration in a single D1 transaction. No Node, no separate auth service, no Lambda cold start.
Code: Push Notification Dispatch via Durable Object
The real-time piece of the mobile backend is push notification dispatch. The pattern: a Durable Object per user holds the user's online devices and dispatches push notifications to all of them.
// app/server/push.ts — Durable Object per user for push routing
import { DurableObject } from 'cloudflare:workers';
interface Device {
id: string;
pushToken: string;
platform: 'ios' | 'android';
lastSeenAt: number;
}
export class PushRouter extends DurableObject {
devices: Map<string, Device> = new Map();
async registerDevice(device: Device): Promise<void> {
this.devices.set(device.id, device);
// Persist to D1 for cold-start recovery
await this.env.DB.prepare(
'INSERT OR REPLACE INTO devices (id, push_token, platform, last_seen_at) VALUES (?, ?, ?, ?)'
).bind(device.id, device.pushToken, device.platform, Date.now()).run();
}
async dispatch(payload: { title: string; body: string; data?: unknown }): Promise<void> {
const routes: Promise<Response>[] = [];
for (const device of this.devices.values()) {
if (device.platform === 'ios') {
routes.push(this.sendAPNs(device.pushToken, payload));
} else {
routes.push(this.sendFCM(device.pushToken, payload));
}
}
await Promise.allSettled(routes);
}
private async sendAPNs(token: string, payload: any): Promise<Response> {
return fetch('https://api.push.apple.com/3/device/' + token, {
method: 'POST',
headers: {
authorization: `bearer ${this.env.APNS_KEY}`,
'apns-topic': this.env.APNS_BUNDLE_ID,
'apns-push-type': 'alert',
},
body: JSON.stringify({ aps: { alert: payload } }),
});
}
private async sendFCM(token: string, payload: any): Promise<Response> {
return fetch('https://fcm.googleapis.com/fcm/send', {
method: 'POST',
headers: {
authorization: `key=${this.env.FCM_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({ to: token, notification: payload }),
});
}
}
The Durable Object holds the in-memory device map, persists to D1 for cold-start recovery, and fans out pushes to APNs and FCM in parallel. The mobile client hits this Durable Object via a WebSocket, so the user's online devices stay in sync without polling.
The webhook receiver for Stripe (or any third-party payment processor) is a sibling Worker that writes to a D1 queue table; the Durable Object drains the queue and dispatches pushes. This separation means a Stripe outage cannot block push delivery, and vice versa. Each component fails independently.
How the Pieces Wire Together in Production
The mobile backend has four request entry points, each routed through the same Cloudflare Worker but executing different bindings:
- Mobile API requests — HTTPS REST endpoints under
/api/mobile/*, handled by TanStack Start Server Functions, reading from D1 and KV. - Stripe webhooks — POST endpoint at
/api/webhooks/stripe, idempotency-keyed, writing to D1 with a Durable Object enforcing exactly-once processing. - WebSocket connections —
/api/mobile/ws, upgrading to Durable Object per user, fanning out push notifications and live state. - Static assets (rare on mobile backends) — R2-backed static files for downloadable reports, exported CSVs, generated PDFs.
All four entry points share the same Cloudflare Worker codebase and the same bindings (D1, R2, KV, Durable Objects). The codebase is one TypeScript project; the wrangler.toml file declares the bindings once. There is no separate "WebSocket service" or "API service" — only one Worker with multiple route handlers.
Cost Math: 1M req/day for $5/mo
The hosting bill for the mobile backend at 1.1M requests/day peak (April 2026 invoice):
| Line item | Usage | Cost |
|---|---|---|
| Cloudflare Workers Paid plan | 33M requests/mo | $5.00 |
| Cloudflare D1 | 25M reads, 200k writes | $0.00 (included) |
| Cloudflare KV | 3M reads, 100k writes | $0.00 (included) |
| Cloudflare R2 | 8GB stored, 2TB egress | $0.12 |
| Cloudflare Durable Objects | 800k requests | $0.15 |
| APNs / FCM push | 200k pushes | $0.00 |
| Total | $5.27/mo |
The dominant cost line is the Workers Paid plan at $5/mo, which covers up to 30M requests. Above 30M, Workers charges $0.30 per additional million. At 100M requests/mo the bill is $26/mo. Still cheaper than a single t3.medium EC2 instance running 24/7.
The mobile client itself does not generate Workers costs — only API calls do. Inactive users (the majority on any mobile app) cost exactly $0 to serve.
The full invoice scaling curve I observed from October 2025 through April 2026:
| Month | Req/day | Workers | D1 | KV | R2 | DO | Total |
|---|---|---|---|---|---|---|---|
| Oct 2025 | 12k | $0 | $0 | $0 | $0.03 | $0 | $0.03 |
| Dec 2025 | 180k | $0 | $0 | $0 | $0.06 | $0.04 | $0.10 |
| Feb 2026 | 620k | $0 | $0 | $0 | $0.09 | $0.09 | $0.18 |
| Apr 2026 | 1.1M | $5.00 | $0 | $0 | $0.12 | $0.15 | $5.27 |
The non-linear cost curve (3 orders of magnitude in traffic, ~175x in cost) is the headline result. A Node server scaled to this curve would have cost ~$80-$120/mo by April (two t3.medium instances plus RDS Postgres). The cost differential funds a year of incremental product development.
Honest Tradeoffs: Where Workers Lose for Mobile
I will not pretend Workers wins every mobile backend comparison. Four cases where you should pick a different stack:
- Long-running CPU work. Workers have a 30-second wall-clock limit on a single invocation (15s on the free plan). If your mobile API needs to do ML inference, video transcoding, or any CPU-bound task over 30 seconds, you need a different runtime. Workers AI runs models in under 30s for most inference; anything else needs a queue + dedicated compute.
- State-heavy workloads in memory. Workers have a 128MB memory limit per isolate. If your API holds a large in-memory cache, runs a heavy ORM, or processes large payloads, you will hit the limit. The solution is to stream large responses via the Streams API and keep heavy processing off the request path.
- iOS NSURLSession quirks at edge. iOS clients using URLSession sometimes retry failed requests with HTTP/1.1 fallback, which can cause unexpected duplicate POSTs on /checkout-style endpoints. The fix is an idempotency-key header on every state-changing endpoint and a D1 idempotency table keyed on the header value.
- Apps with strict regulatory residency. Workers run in Cloudflare's edge network (330+ cities). If you need data to remain in a specific jurisdiction (EU-only, US-only), Workers' default routing may not satisfy compliance. Workers Paid customers can configure region filtering per route, but this is a configuration overhead, not a default.
For a deeper comparison with Supabase and Firebase as mobile backends, see our TanStack Ship vs Supastarter and TanStack Ship vs Makerkit breakdowns.
Decision Tree: When Workers, When Supabase, When Bare Node
Pick Cloudflare Workers + D1/R2/KV/DO if:
- Your mobile API is request/response or WebSocket-based with sub-30-second response times
- Your users are globally distributed and edge latency matters
- Your budget is tight at low scale and you want predictable cost growth
- You do not need Node-only packages or long-running CPU
Pick Supabase or Firebase if:
- You need a managed Postgres (Supabase) or Firestore (Firebase) with a built-in SDK for your mobile platform
- You want realtime subscriptions without writing WebSocket glue
- Your team is already proficient in one of these ecosystems
Pick Bare Node (Express, Fastify, Hono) on Fly.io or Railway if:
- You need packages that depend on Node built-ins (sharp for image processing, native bindings)
- Your API has long-running streaming responses
- You are comfortable managing a server and want full control
For pricing comparison across these stacks, see the TanStack Ship pricing page.
FAQ
Does Cloudflare Workers support WebSockets for mobile apps? Yes. The WebSocket API is stable on Workers and supports bidirectional real-time messaging. The mobile client uses URLSessionWebSocketTask on iOS or OkHttp WebSocket on Android. See the WebSocket documentation.
Can D1 handle concurrent writes from many mobile clients? Yes, with caveats. D1 uses SQLite's WAL mode and serializes writes per database. For high-write workloads (>1k writes/sec sustained), you need to either shard across multiple D1 databases or move to a different storage layer. For most mobile backends, well under 1k writes/sec, D1 is fine.
What about push notification reliability? APNs and FCM both guarantee at-least-once delivery but not at-most-once. Duplicate pushes happen; the mobile client must deduplicate by a notificationId field in the payload. The Durable Object pattern above handles server-side deduplication by tracking the last delivered notificationId per device.
How do you handle mobile app auth refresh tokens? Issue a short-lived (15-min) access JWT and a long-lived (30-day) refresh token. Store the refresh token in the iOS Keychain or Android EncryptedSharedPreferences. The refresh endpoint validates the refresh token against a D1 sessions table, rotates it, and issues a new access JWT. Standard OAuth 2.0 pattern, fits cleanly in a Worker.
What happens when Cloudflare has an outage? Cloudflare's status page shows historical uptime of 99.99%+ for Workers. For mission-critical mobile apps, the answer is multi-region fallback: replicate the API to AWS Lambda or Fly.io and route via DNS health checks. I have not implemented this for any of my mobile apps yet because the cost-benefit has not favored it.
Do I need TanStack Start to use Workers, or can I use plain Workers? Plain Workers (the fetch handler API) works for any backend. TanStack Start is the convention for routing, server functions, and end-to-end types. If your team prefers Hono or itty-router, those work just as well. See the Workers documentation for the minimal setup.
How do you handle rate limiting at the edge? Each Worker route has a per-IP and per-user rate limit enforced via a Durable Object that holds a sliding-window counter. Limits reset every 60 seconds. The default limit is 100 req/min per user for authenticated endpoints and 30 req/min per IP for unauthenticated. Above the limit, the Worker returns 429 with a Retry-After header. No external API gateway required.
What about analytics and observability for the mobile API? Workers Analytics Engine (still beta in 2026) is the cheap path: write a single writeDataPoint() call per request and pay $0.05 per million data points ingested. For deeper traces, Cloudflare Logs can be streamed to R2 and parsed with wrangler tail during development or a third-party APM during incidents. Avoid logging large request/response bodies — every log byte counts against your Workers bundle limits if you buffer in memory.
Can the mobile backend talk to internal services behind a VPN? Workers cannot reach a private VPC directly. For mobile backends that need to call internal APIs on AWS or GCP, the standard pattern is an outbound HTTP call over the public internet with mTLS authentication. Cloudflare's service bindings let one Worker call another Worker directly without going over the public internet, which is useful for splitting a mobile backend across multiple Workers.
What's the migration path if I outgrow Workers? The two paths I have seen work in production: (1) move heavy state out of Durable Objects into a managed Postgres (Neon, Supabase) and keep Workers as the API layer; (2) move the whole stack to a container-based runtime (Fly.io, Railway) when you need long-running CPU or >128MB memory. The Workers' TanStack Start codebase ports cleanly to either destination because the Server Functions pattern is portable — only the binding declarations change.
Closing
A mobile app backend on Cloudflare Workers is real, production-grade, and cheap. The architecture is four bindings — D1, R2, KV, Durable Objects — wired together in one Worker per route group. 50ms p99 cold start. $5.27/mo at 1.1M requests/day. Single solo developer for seven months with no pages.
If you are starting a mobile SaaS this quarter and your backend fits the request/response or WebSocket shape, clone TanStack Ship and have the mobile auth endpoint running in under an hour. The four bindings and the deploy pipeline are already wired.