The edge is boring now — and that is the point
What a free Cloudflare Worker and D1 can carry, where the free tier stops being free, and the API shape that kept BundleWala simple.
By Muhammad Naeem
There was a period when "serverless at the edge" sounded like a architecture diagram competition. The useful version is much duller: put a small API close to the user, keep the database managed, and stop paying for an idle server.
BundleWala runs its entire backend on a Cloudflare Worker and D1. It serves 286 plans, carrier metadata, auth-linked favourites and trackers, feedback, remote config and push notifications. The monthly infrastructure bill is zero.
That is not a flex. It is a constraint that shaped the product.
A Worker is not a server
A Worker is a request handler with a short life. There is no long-running process, no local disk, no in-memory cache you can trust between requests. You get a binding to D1, a few environment variables, and a CPU budget.
That makes it a good fit for:
- read-heavy JSON APIs;
- small writes with validation;
- webhooks and notifications;
- auth token exchange;
- an MCP surface for agents.
It is a bad fit for:
- video or large file processing;
- long-running jobs;
- websockets with server state;
- heavy reporting queries over millions of rows;
- anything that needs a persistent queue in the same process.
We kept the boundary honest: the Worker serves the catalogue and small user records. The app does the AI and the offline work. Nothing on the server needs to remember a conversation.
The shape that worked
The API is a Hono app with a small set of routes:
GET /health
GET /api/v1/networks
GET /api/v1/packages?limit=200&offset=0
GET /api/v1/packages/:id
POST /api/v1/auth/*
GET /api/v1/favourites
POST /api/v1/trackers/sync
POST /api/v1/feedback
GET /api/v1/config
POST /mcp
Every list endpoint is paginated. Every write is validated with Zod. Every response is JSON with a stable shape. The app can cache it, page it, and retry it without special cases.
D1 holds eight tables: networks, packages, users, favourites, trackers, feedback, app config and an audit log. The schema is in SQL, versioned in the repo, and applied with Wrangler. There is no ORM to hide the queries, which is a feature at this size.
The free tier is a design constraint
Cloudflare's free plan gives you 100,000 Worker requests per day and a D1 allowance measured in rows read and written. That sounds enormous until a client polls.
The first version of the app fetched the full catalogue on every launch. With 286 rows and a 200-row page size, that is two requests per launch. Fine. But a naive implementation that fetched per network, per filter or per screen would burn the budget and make the app slower.
We made three decisions because of the limit:
- Page the catalogue once, cache it locally. The app uses the local copy for search, filters and sorting. The API is a sync source, not a query engine per keystroke.
- Separate metadata from content. Carrier names, colours and logos change rarely. A cheap
/networkscall can run without refetching 286 plans. - Refresh on a schedule, not on every screen. The app refreshes on launch when the cache is older than six hours, and on a manual pull. It does not call the API when the user taps a filter.
The limit did not make the product worse. It made the data flow explicit.
Pagination is not optional
The API defaults to 50 rows when no limit is given. The first client fetch asked for "all packages" and silently got 50. The UI looked complete because the first network happened to have fewer than 50 rows in the default sort.
The fix was a loop:
let offset = 0;
const all = [];
while (true) {
const page = await fetchPackages({ limit: 200, offset });
all.push(...page);
if (page.length < 200) break;
offset += 200;
}
Two rules came out of that bug:
- every list endpoint has an explicit maximum and a stable order;
- the client proves it reached the end before it replaces the cache.
A short page is the end signal. A full page means there might be more. Do not infer completeness from a count you did not request.
When the edge is the wrong tool
If the product needs a long-running job, use a queue and a worker that can run longer. If it needs a large relational query with complex joins, use a database that is designed for it. If it needs a persistent websocket, use a service that keeps state.
The edge is a good default for a small JSON API with a global audience and a tight budget. It is not a religion.
What I would do again
- Keep the API small and boring.
- Put the schema in SQL and review it like code.
- Paginate everything and test the short-page boundary.
- Treat metadata as a separate cache from content.
- Make the app work without the API.
- Measure the request budget before adding a polling feature.
The best part of the edge is not the latency graph. It is that a small team can run a real backend without a monthly bill or an operations rota. Boring is the feature.
Written by Muhammad Naeem · 12 Sept 2026
Reply by email