🪴 GoDeep
Search
← Shipping Software

Edge or cloud: choosing where a job runs

Decide, with reasons you can defend, whether a piece of work belongs on the Cloudflare edge or on an Azure App Service or Container App — and how to split one system across both.

12 cards · 6 quiz questions · 10 min read

You have eighteen Pages sites and a FastAPI stack on Azure, and every so often an agent offers to move something from one to the other — “let’s make this a Worker, it’ll be faster” or “let’s put the whole API on the edge”. Sometimes that is right. Often it is a rewrite that trades a working system for a set of limits nobody mentioned. This article gives you the questions to ask, with the actual numbers behind them.

What “the edge” is, mechanically

On Azure, you have a machine. An App Service plan is reserved capacity: a process starts, stays running, holds a connection pool, has a filesystem, and waits for requests.

On Cloudflare, you have none of that. Your code is distributed to every data centre, and when a request arrives an isolate — a lightweight sandbox, much cheaper to start than a container — runs your handler and then goes away. There is no machine of yours anywhere, and nothing survives between requests except what you deliberately wrote to KV, D1 or R2.

Everything else follows from that. A short-lived, shared sandbox means the platform meters computation tightly and caps memory. Cloudflare’s limits page is the document to internalise:

FreePaid
CPU time per invocation10 msup to 5 minutes
Memory per isolate128 MB128 MB
Subrequests per request5010,000
Cron / Queue invocation—15 min max

The one number people misread: CPU time

CPU time is time your code spends computing. Time spent awaiting a fetch, a D1 query or an LLM call costs almost none of it. And an HTTP request has no wall-clock limit while the client stays connected.

// FINE on the free plan: 18 seconds of waiting, ~1 ms of CPU
export default {
  async fetch(request: Request, env: Env) {
    const [a, b, c] = await Promise.all([          // waiting is nearly free
      fetch("https://api.example.com/waves").then((r) => r.json()),
      fetch("https://api.example.com/quotas").then((r) => r.json()),
      env.DB.prepare("select count(*) as n from responses").first(),
    ]);
    return Response.json({ a, b, c });
  },
};
// NOT fine on the free plan: this is all CPU, and 10 ms goes fast
const text = await (await fetch(csvUrl)).text();     // waiting: free
const rows = text.split("\n").map((l) => l.split(","));  // computing: metered
const cleaned = rows.filter(looksValid).map(normaliseOpenEnd); // 200k rows?

So “is this too slow for a Worker?” is the wrong question. The question is “how much of this time is my code thinking?” Parsing, decompressing, hashing in a loop, sorting a large array, rendering a big template: those are CPU. Waiting is not.

Python, pandas, and big dependencies

This is where the answer is usually a flat no, and it is worth knowing why rather than being told.

Cloudflare does support Python Workers, but the packaging model is the constraint. The docs state Workers support pure-Python and PyEmscripten wheels from PyPI plus packages bundled with Pyodide, and note that WebAssembly support for Python packages is still in early stages. You cannot pip install whatever you like. Even where a wheel exists, a dataframe over a real survey export will meet the 128 MB isolate and the CPU meter long before it meets a business requirement.

The same test applies on the JavaScript side. Node compatibility at the edge is partial: several modules are present only as non-functional stubs — importable, so a package’s feature detection succeeds, but unusable in practice. That is a nastier failure than an import error, because it surfaces at runtime in production.

A useful heuristic: does this dependency assume an operating system? If it shells out to a binary, treats the filesystem as a real disk, loads a compiled extension, or pulls in a machine-learning framework, it wants a container.

# Belongs on Azure — ordinary CPython, real pandas, real memory
import pandas as pd

def clean_wave(path: str) -> pd.DataFrame:
    df = pd.read_csv(path, dtype_backend="pyarrow")
    df = df[df["completion_status"] == "complete"]
    # 60 seconds of CPU and 2 GB of RAM: unremarkable here, impossible at the edge
    return df.assign(weight=lambda d: d["weight"].fillna(d["weight"].median()))

Stateful connections

A connection pool works because one long-lived process keeps sockets open and reuses them. Edge isolates are many, short-lived and spread across hundreds of locations, so there is no process to hold a pool — and if there were, thousands of isolates each opening a Postgres connection would exhaust the database.

This is why Cloudflare’s storage story is bindings rather than connection strings: D1, KV and R2 are reached through a handle the platform authorises, not a socket you manage. If a piece of code genuinely needs a persistent connection — a WebSocket fan-out you coordinate yourself, a database driver with session state, a message-queue consumer that holds a channel — that is a server-shaped requirement.

Long-running jobs

Your nightly survey import takes half an hour. Nothing at the edge fits that: Cron Trigger and Queue Consumer invocations top out at 15 minutes, and anything triggered by HTTP is bounded by the client staying connected.

Microsoft’s own comparison positions Azure Container Apps for containerised microservices and for on-demand, scheduled and event-driven jobs, with scale to zero between runs, and App Service for web applications and web APIs. That maps cleanly: the FastAPI service on App Service or Container Apps, the long import as a Container Apps job.

The underlying rule is not about platforms at all: if the caller should not have to wait for it, it should not be inside the call. An endpoint that accepts an upload, returns 202 Accepted with a job id, and does the work elsewhere is the right shape wherever it runs.

# FastAPI: hand off, don't hold the request open
@router.post("/imports", status_code=202)
async def start_import(
    body: ImportRequest,
    queue: Annotated[QueueClient, Depends(get_queue)],
) -> ImportAccepted:
    job_id = uuid4().hex
    await queue.send(body.model_dump() | {"job_id": job_id})  # Container Apps job picks it up
    return ImportAccepted(job_id=job_id, status_url=f"/imports/{job_id}")

Compliance and data residency

Two separate questions: where data is stored, and where it is processed.

R2 lets you create a bucket with a jurisdiction — eu, fedramp, us — which guarantees objects stay in that territory, and the docs are explicit that it cannot be changed after creation. Location hints are a different thing: best effort, not a guarantee, and not a compliance control.

// The jurisdiction is part of the binding, and is fixed at bucket creation
{ "r2_buckets": [
    { "binding": "EXPORTS", "bucket_name": "iff-exports", "jurisdiction": "eu" }
] }

Processing location at the edge is inherently distributed — that is the product. When a client contract names a region, a legal entity and a data-processing agreement, a single Azure region is usually the easier story to write down and defend. Reach for the edge for the public, cacheable surface, and keep respondent-level data where you can point at it on a map.

Cost shapes

The amounts matter less than the shape.

  • Workers bill per request plus CPU milliseconds actually consumed. Idle costs nothing. Waiting on a slow upstream is close to free. The bill tracks traffic and computation.
  • App Service bills for reserved capacity by the hour, whether or not anyone visits. The bill tracks time.
  • Container Apps sit between the two, scaling to zero when idle.

A bursty public site with long quiet periods is cheap at the edge and wasteful on a reserved plan. A steady internal pipeline chewing CPU all day is the reverse. The failure mode to watch is a per-request bill on an accidental hot path: a Worker on your critical asset route that recomputes something it could have cached is a bill that scales with your traffic.

Storage: KV, D1, or neither

Good atWatch out for
KVHot reads of rarely-changing values: sessions, rate limits, a cached digestEventually consistent — writes can take 60 seconds or more to appear elsewhere; roughly one write per second to the same key
D1Real SQL, joins, transactions, up to gigabytes per databaseNot a warehouse; per-invocation query caps; one region is the primary
R2Large objects, exports, audio, documents; no egress feeNot a database; no queries

The common mistake is a hot counter in a single KV key. It throttles, and it reads stale. Use D1 or a Durable Object for anything that coordinates.

The default hybrid

For your world, this is the shape that usually wins:

  • Cloudflare Pages serves the Astro or React front end. Hashed bundles cached hard, HTML revalidated. No server involved in a page view.
  • Pages Functions or a Worker handle the thin edge layer: auth cookie checks against KV, rate limiting, a cached summary endpoint, proxying an LLM call so the key stays server-side.
  • Azure App Service or Container Apps runs FastAPI: the pandas work, the document processing, the database connections, the scheduled jobs.
  • D1 and KV hold light edge state so the front end can answer instantly without waking the API.
  • R2 holds exports the browser downloads directly, keeping large files off your API’s bandwidth.

And when an agent proposes moving something: ask where the milliseconds actually go. If an endpoint’s time is spent in a database in one Azure region, relocating the wrapper to the edge leaves that distance untouched and adds a hop. Caching the response is almost always the bigger win than moving the compute.

What to check in your own code

  • For every Worker or Pages Function, find the loop or parse that is doing real work, and ask how many items it handles at the 99th percentile. Anything unbounded by input size is a CPU-limit incident waiting for a big client.
  • Grep Worker code for node:fs, node:child_process, node:worker_threads and node:cluster. Several exist as non-functional stubs, so a clean build proves nothing.
  • Check compatibility_flags for nodejs_compat and note your compatibility_date — Node support at the edge depends on both.
  • Find anything scheduled. If a Cron Trigger drives work that could exceed 15 minutes as data grows, plan the move to a Container Apps job before it fails.
  • Look for a single KV key being written on every request. That is a throttle and a stale read.
  • List your R2 buckets and check which have a jurisdiction set. If a client contract names the EU and the bucket has no jurisdiction, that cannot be fixed in place — a new bucket and a copy is the only route.
  • Before agreeing to any edge rewrite, time the current endpoint and write down where the time goes. If it is not in the network hop to your origin, the edge will not fix it.

Sources

  • Cloudflare — Workers platform limits website CPU time per invocation (10 ms free, up to 5 minutes paid), 128 MB memory per isolate, and subrequest caps — the numbers that decide whether work fits at the edge.
  • Cloudflare — Python packages in Cloudflare Workers website Python Workers run pure-Python and PyEmscripten wheels plus Pyodide's bundled packages; WebAssembly packaging is still early, so arbitrary pip installs do not work.
  • Microsoft — Comparing Container Apps with other Azure container options website Microsoft's own guidance: App Service for web apps and web APIs, Container Apps for containerised microservices and on-demand or scheduled jobs with scale to zero.
  • Cloudflare — Data location and jurisdictional restrictions in R2 website Jurisdictions (eu, fedramp, us) guarantee objects stay in a territory, cannot be changed after bucket creation, while location hints are best-effort only.