The value proposition of Workers becomes clear when you see what is possible in a single handler: fetching a record from D1 with SQL, checking an entry in KV, fetching an object from R2, calling an internal service via Service Binding, and enqueuing asynchronous work into a Queue — all within the same invocation, with each binding injected as a property of env and available with one line of code. No separate SDK to instantiate, no network configuration to manage, no credentials floating around in environment variables. wrangler.toml declares the bindings, the runtime delivers them. The issue is that each of these operations consumes a subrequest of the invocation budget, and the bill appears sooner than it seems.
The bindings model and what it solves
Bindings are how the Workers runtime connects its code to the platform's capabilities without exposing credentials or network configuration. When you declare [[d1_databases]] in wrangler.toml with a database_id, the runtime injects an object with the D1 interface into env.DB. When you declare [[kv_namespaces]] with a id, the runtime injects the KV interface into env.CACHE. There is no authentication token, there is no endpoint URL, there is no third-party SDK — binding is a direct call into the Cloudflare infrastructure.
This has consequences for safety and operation. A compromised Worker has no credentials to exfiltrate — it can only operate the bindings that have been declared for it, with the permissions those bindings have. For a Worker that only needs to read from the KV, you declare the binding as read-only and the Worker literally cannot write, regardless of what the code tries to do. This separation of capabilities is stronger than a permission check in code.
For staging and production, each environment has its own binding IDs in wrangler.toml. The code does not change — env.DB remains env.DB — but in staging it points to a different D1 bank, a different KV namespace, a different R2 bucket. There is no risk of staging code touching production data because the bindings are physically separated.
The subrequest budget and how it is consumed
The free plan has 50 subrequests per invocation. The paid plan has 1000. Each operation that leaves isolate counts: fetch() for any URL, env.KV.get(), env.KV.put(), env.DB.prepare().run(), env.BUCKET.get(), env.QUEUE.send(), env.SERVICE.fetch(). A call to env.DB.batch() with 5 queries counts as 1 subrequest — this detail is critical.
A typical handler of an API that returns an enriched user profile: searches for the user in D1 by ID (1), searches for preferences in KV (2), checks for a profile photo in R2 (3), calls an authorization service via Service Binding (4). Total: 4 subrequests in the happy route. Scales to 1000 concurrent users and is still within the limit — 4 subrequests per invocation is no problem.
The problem arises with loops. A handler that processes a list of items and makes a D1 query per item — the classic N+1 — explodes quickly. Fifty items with one query each reach the free plan limit on the first call. With 200 items on the paid plan, it is still within 1000, but the accumulated latency of 200 sequential D1 queries will be hundreds of milliseconds. The error when the limit is reached arrives as a network error in the subrequest that exceeded — no clear message in the body of the response to the client, just an exception in the tail.
Concrete optimizations by binding
For D1, db.batch() is the most important optimization. Instead of doing prepare().run() in sequence for multiple queries, you pass an array of statements to batch() and receive an array of results — at a cost of 1 total subrequest. For queries that do not depend on each other (searching for configs of different types, for example), batching eliminates sequential latency and the expense of subrequests at the same time.
Parallel D1 queries — Promise.all([db.query1, db.query2]) — still count as separate subrequests, but they execute in parallel and latency is determined by the slowest, not the sum. Use Promise.all() when the results are independent and you do not need the batch for another reason. Use batch() when you want to consolidate the subrequest cost.
For KV, the most valuable pattern is the module cache. KV has network latency—a few milliseconds even at best—and rarely changing configuration data does not need to be re-read with each invocation. A module can declare a variable in global scope:
let cachedConfig = null; export default { async fetch(request, env) { if (!cachedConfig) { cachedConfig = JSON.parse(await env.CONFIG.get('app-config')); } // usa cachedConfig } }
As long as the isolate lives in the PoP, subsequent invocations reuse the value without wasting a subrequest. The risk is staleness — if the configuration changes, the cached isolate still uses the old version until discarded. For data that needs to be near-real-time, KV with low cacheTtl or invocation rereading is more appropriate. For feature flags and infrastructure configs that change rarely, the module cache is efficient.
For R2, the binding supports range requests — env.BUCKET.get(key, { range: { offset, length } }) — which allows you to fetch just the part of an object you need instead of the entire file. For large files where you need a header, embedded metadata, or the first few lines of a CSV, range request saves memory and transfer time. The answer is a ReadableStream that can be piped directly to the client without buffering.
Composition of multiple bindings in the same request
The real power comes when you need multiple types of data to construct an answer. A product API that returns a search page: SQL query in D1 for IDs that match the filter, parallel read in KV for prices (which change frequently and live in KV for read performance), and pre-signed URL from R2 for each product's main image.
This composition works naturally — env.DB, env.PRICES, env.ASSETS are all available in the same handler. The design that doesn't work well is doing this in sequence for each item in a list. The correct version: query D1 returns 20 IDs, Promise.all() for the 20 KV reads in parallel (20 subrequests, but in parallel), Promise.all() for the 20 R2 URLs (20 more subrequests). Total: 41 subrequests (1 D1 + 20 KV + 20 R2), within the paid limit of 1000, with latency determined by the slowest of the 40 parallel fetches, not the sum of 41.
Monitoring the use of subrequests
The runtime does not expose subrequest counts per invocation directly in the tail. The approach is to instrument: create a simple wrapper that increments a counter with each binding operation and logs the total at the end of the handler. Exported via Logpush, this number allows you to detect when a deployment has increased consumption — before reaching an edge case that exceeds the limit in production.
A handler that uses 500 subrequests when it could use 50 with correct batching and parallelism is wasting latency and budget unnecessarily. Redesigning this handler after an incident in production is more costly than measuring consumption from the beginning and correcting it while N is still small.
Also read
- KV vs R2 vs Cache API: When to use each Cloudflare storage tier
- Cloudflare Workers in production: what changes after hello world
- Workers: debugging, logs and Workers Tail — observability at the edge without a log server
- Workers: CPU and memory limits — which the documentation doesn't explain well
- Testing Workers: unit, integration and how to simulate the runtime without depending on Cloudflare
- Cloudflare KV: What does globally distributed mean when you need to write
