Developing with Cloudflare Workers locally is one of the most fluid experiences serverless has to offer today. The wrangler dev goes up in seconds, the logs appear in the terminal, and everything seems to work exactly as you expect. The problem is that this comfort masks fundamental differences that only appear when the code truly reaches the edge. Many teams discover late that console.log in production goes nowhere unless there is someone actively doing wrangler tail, that buffering a 50MB response kills isolate silently, and that that handler that does 15 queries in D1 plus 10 reads in KV is a time bomb against the subrequest limit.
What happens to your logs in production
Locally, wrangler dev displays every console.log on the terminal in real time. In production, Cloudflare's V8 isolates don't have a persistent process to capture this output — each invocation runs in isolation, executes, and disappears. Logs are only accessible via wrangler tail, which opens a streaming session with Workers in production and relays request metadata, console.log output, uncaught exceptions, and CPU duration.
The critical point: this session does not persist. If there is no one tailing when an error occurs, the log is lost. For retention, the solution is Logpush — which exports logs to R2, S3, Datadog, or Splunk at $0.05 per million rows for R2. But Logpush works with the structured fields that the Worker runtime emits, not with console.log free text. The practical consequence is that useful logs in production need to be structured from the beginning: JSON with fields like requestId, duration, statusCode, error — fields that both wrangler tail can filter and Logpush can export faithfully.
The pattern that works is to create a minimal logging wrapper at the beginning of the project, before you need it. Something that serializes to JSON and writes in console.log, with a field level differentiating info from error. When Logpush comes in, these fields are available for filters and alerts on the target.
Memory: 128MB is smaller than it seems
The 128MB limit per isolate includes the uncompressed script in V8, all module closures, and the entire heap of the current invocation. It's not 128MB for "your data" — it's 128MB for everything, including the runtime itself.
The most common mistake is to call response.arrayBuffer() in large answers. A 30MB file downloaded from another service to be processed and forwarded occupies 30MB of heap instantly. If processing creates more intermediate allocations, the isolate exceeds 128MB and is killed — no catchable exception, no response to the client, just a 1101 error on the outside.
The solution is to use response.body as ReadableStream and process via TransformStream. Instead of buffering and transforming, you create a pipeline where chunks flow: read from upstream, transformed in transit, written to response without ever existing integers in memory. For responses larger than 1MB, the assumption should be streaming; Buffering should be an explicit and justified choice, not the default path.
The same reasoning applies to uploads. The maximum request body is 100MB, but request.arrayBuffer() tries to allocate everything at once. For large uploads, processing must be done in stream as well, or the file must go directly to R2 via put() which accepts a ReadableStream.
Subrequests: the limit that appears at the worst time
Each fetch(), each operation on KV, each query on D1, each reading on R2 counts as a subrequest. On the paid plan, the limit is 1000 per invocation. For free, 50.
A handler that seems reasonable — fetches the user in D1, reads their preferences in KV, pulls the document from R2, calls an external API, saves the result in D1 — is already 5 subrequests into the happy route. If this handler goes into a loop because it processes a list of items, the counter goes up quickly. Fifty items with two operations each are already close to 100. A bug that makes N+1 queries in D1 — searching for each child record individually instead of with a JOIN — can easily overflow 1000 before the end of a single complex request.
The error when the limit is reached is not obvious: the Worker receives a network error on the subrequest that exceeded the limit, which can be confused with instability of the network or downstream service. Correct diagnosis requires viewing the exception logs via wrangler tail and correlating with the handler call pattern.
Mitigation: use db.batch() in D1 to group multiple queries into a single subrequest. Read KV configurations once at module initialization and cache in the global scope — the isolate can be reused between requests in the same PoP, and the KV reading that happened in the first invocation does not need to be repeated in subsequent invocations as long as the isolate lives.
Secrets, environments and the wrangler.toml that goes to the repository
Variables declared in wrangler.toml under [vars] are clear text in the configuration file, which normally goes into the repository. For any sensitive value — API keys, bank tokens, webhook secrets — the only correct option is Workers Secrets, declared as [secrets] in wrangler.toml and stored via wrangler secret put. The value is encrypted at rest and injected at runtime as a property of env, without appearing in any build logs or artifacts.
The wrangler.toml of a service going to production must have explicit environments: [env.staging] and [env.production] with their own separate bindings, routes and secrets. Mixing staging and production in the same set of bindings is an accident waiting to happen — especially when D1 and KV have real data in production and test data in staging. Separating environments in wrangler.toml also allows the CI pipeline to automatically deploy to staging and require manual approval for production, without any additional logic in the deploy script.
What a minimum production setup looks like
A production-ready wrangler.toml references compatibility_date explicitly (so as not to receive breaking changes from the runtime without warning), defines [observability] with enabled = true to expose basic metrics on the dashboard, and separates [env.staging] from [env.production] with different routes. Secrets are listed by name with no value — the value exists only in Cloudflare, never in the repo.
The main handler has a try/catch at the highest level that catches any unhandled exceptions, logs it as JSON structured with console.error, and returns an HTTP 500 response with a traceable requestId. Without this boundary, an unexpected exception could return a 200 with truncated body to the client while the real error appears only in the tail — and only if someone is looking.
Also read
- Cloudflare Workers: Practical Guide to Serverless Edge Computing
- D1 in production: performance, limits and what doesn’t scale alone
- WebAssembly at the edge: Why starting fast and isolated matters
- 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
- Developing serverless applications with AWS Lambda and Cloudflare Workers in 2025
